安卓-NDK-游戏开发秘籍-全-
安卓 NDK 游戏开发秘籍(全)
原文:
zh.annas-archive.org/md5/713F9F8B01BD9DC2E44DADEE702661F7
译者:飞龙
前言
移动性和对高性能计算的需求往往是紧密相连的。当前的移动应用程序执行许多计算密集型操作,如 3D 和立体渲染、图像和音频识别、视频解码和编码,尤其是随着增强现实等新技术诞生。这包括移动游戏、3D 用户界面软件和社交软件,涉及媒体流处理。
在某种意义上,移动游戏开发由于硬件能力的限制、内存带宽的不足和宝贵的电池资源,迫使我们回到几年前,但也让我们重新考虑与用户互动的基本形式。
基于手势输入、互联网访问、环境音效、高质量的文本和图形的流畅且响应迅速的用户界面是成功移动应用程序的要素。
所有主流的移动操作系统都为软件开发者提供了不同方式接近硬件的开发可能。谷歌提供了 Android 原生开发工具包(NDK),以简化将其他平台上的现有应用程序和库移植到 Android,并利用现代移动设备提供的底层硬件性能。C 语言,尤其是 C++,都因其难学难写用户界面代码而闻名。这确实是事实,但仅当有人试图从零开始编写一切时。在这本书中,我们使用 C 和 C++编程语言,并将它们与久经考验的第三方库链接起来,以允许创建具有现代触摸界面和访问诸如 Facebook、Twitter、Flickr、Picasa、Instagram 等流行网站的代表性状态转移(REST)API 的内容丰富的应用程序。
尽管关于如何在用 Java 或.NET 语言编写的应用程序中使用互联网资源的信息已经很多,但在 C++编程语言中这样做却鲜有讨论。现代 OpenGL 版本要求投入足够努力来创建和使用最新的扩展。使用 OpenGL API 的编程通常在文献中以特定平台的方式描述。对于移动版本 OpenGL ES,事情变得更加复杂,因为开发者必须调整现有的着色器程序,使它们能在移动图形处理单元(GPU)上运行。在 C++中使用标准的 Android 设施进行声音播放也不是那么直接,例如,需要采取措施复用现有的 PC 代码以便于 OpenAL 库的使用。这本书试图阐明这些主题,并将许多有用的食谱结合起来,简化使用 Android NDK 的多平台友好开发。
Android 是一个基于 Linux 内核的移动操作系统,专为智能手机、平板电脑、上网本和其他便携设备设计。Android 的初步开发由 Android Inc 开始,该公司于 2005 年被 Google 收购。2007 年 11 月,第一个版本公布,然而,第一款基于 Android 的商业智能手机 HTC Dream 在 2008 年几乎一年后发布。
除了数字编号,Android 版本还有官方的代号名称——每个主要版本都是以甜点命名。以下是与 NDK 相关的 Android 平台技术和功能的一些重要里程碑:
-
版本 1.5(纸杯蛋糕):这个 Android 版本首次发布了支持 ARMv5TE 指令的 Android 本地开发工具包。
-
版本 1.6(甜甜圈):首次引入了 OpenGL ES 1.1 本地库支持。
-
版本 2.0(闪电泡芙):支持 OpenGL ES 2.0 本地库。
-
版本 2.3(姜饼):
-
Dalvik VM 中的并发垃圾收集器。这提供了更快的游戏性能和改进的 OpenGL ES 操作效率。
-
本地开发工具包的功能得到了极大的扩展,包括传感器访问、本地音频 OpenSL ES、EGL 库、活动生命周期管理和对资产的本地访问。
-
-
版本 3.0(蜂巢):
-
支持大型触摸屏的平板电脑
-
支持多核处理器
-
-
版本 4.0(冰淇淋三明治):
-
统一的智能手机和平板界面
-
硬件加速的 2D 渲染。VPN 客户端 API
-
-
版本 4.1 和 4.2(果冻豆):
-
这提高了渲染性能和三重缓冲
-
支持外部显示器,包括通过 Wi-Fi 连接的外部显示器
-
它们支持高动态范围相机
-
新内置的开发者选项,用于调试和性能分析。Dalvik VM 运行时优化。
-
-
版本 4.3(果冻豆):支持 OpenGL ES 3.0 本地库。
-
版本 4.4(奇巧):从 NDK 引入了 RenderScript 的访问。此功能与运行 Android 2.2 或更高版本的任何设备向后兼容。
Android 本地开发工具包(NDK)用于需要 Dalvik 无法提供的性能的多媒体应用程序,以及直接访问本地系统库。NDK 也是可移植性的关键,反过来,它允许使用熟悉的工具(如 GCC 和 Clang 工具链或类似工具)进行相当舒适的开发和调试过程。NDK 的典型使用决定了本书的范围——集成一些最常用的 C/C++ 库,用于图形、声音、网络和资源存储。
最初,NDK 是基于 Bionic 库的。这是由 Google 为 Android 开发的 BSD 标准 C 库(libc)的一个衍生品。Bionic 的主要目标如下:
-
许可:原始 GNU C 库(glibc)是 GPL 许可的,而 Bionic 拥有 BSD 许可。
-
大小:与 GNU C 库相比,Bionic 的体积要小得多。
-
速度:Bionic 针对相对低时钟频率的移动 CPU 设计。例如,它有一个自定义的 pthreads 实现。
Bionic 在完整 libc 实现中缺少许多重要特性,例如 RTTI 和 C++ 异常处理支持。然而,NDK 提供了几个带有不同 C++ 辅助运行时的库,这些库实现了这些特性。这些包括 GAbi++ 运行时、STLport 运行时和 GNU 标准 C++库。除了基本的 POSIX 特性外,Bionic 还支持 Android 特定的机制,如日志记录。
NDK 是一种非常有效的方式来复用大量的现有 C 和 C++ 代码。
本书涵盖的内容
第一章,建立构建环境,解释了如何在 Microsoft Windows 和 Ubuntu/Debian Linux 发行版上安装和配置 Android SDK 和 NDK,以及如何在基于 Android 的设备上构建和运行你的第一个应用程序。你将学习如何使用 Android NDK 附带的不同的编译器和工具链。本章还涵盖了使用 adb 工具进行调试和部署应用程序的内容。
第二章,移植通用库,包含一系列将久经考验的 C++ 项目和 API 移植到 Android NDK 的方法,例如 FreeType 字体渲染库、FreeImage 图像加载库、libcurl 和 OpenSSL(包括编译 libssl 和 libcrypto)、OpenAL API、libmodplug 音频库、Box2D 物理库、Open Dynamics Engine (ODE)、libogg 和 libvorbis。其中一些需要对源代码进行修改,这将在文中解释。这些库中的大多数将在后续章节中使用。
第三章,网络编程,展示了如何使用知名的 libcurl 库通过 HTTP 协议下载文件,以及如何使用 C++ 代码直接向流行的 Picasa 和 Flickr 在线服务形成请求和解析响应。如今,大多数应用程序在某种程度上都会使用网络数据传输。HTTP 协议是所有流行网站(如 Facebook、Twitter、Picasa、Flickr、SoundCloud 和 YouTube)API 的基础。本章的剩余部分致力于 Web 服务器开发。在应用程序中拥有一个迷你 Web 服务器可以让开发者远程控制软件,监视其运行时,而不使用特定于操作系统的代码。本章开头还介绍了用于后台下载处理的任务队列和简单的智能指针,以允许跨线程高效交换数据。这些线程原语在第四章,组织虚拟文件系统和第五章,跨平台音频流中会被使用。
第四章, 组织虚拟文件系统,完全致力于异步文件处理、资源代理和资源压缩。许多程序将其数据存储为一系列文件。在不阻塞整个程序的情况下加载这些文件是一个重要的问题。所有现代操作系统的人机界面指南规定应用程序开发者应避免在程序工作流程中出现任何延迟或“冻结”(在 Android 中称为应用程序无响应(ANR)错误)。Android 程序包只是带有.apk 扩展名的熟悉 ZIP 算法压缩的归档文件。为了允许直接从.apk 读取应用程序的资源文件,我们必须使用 zlib 库解压.zip 格式。另一个重要的话题是虚拟文件系统概念,它允许我们对底层的操作系统文件和文件夹结构进行抽象,并在 Android 和 PC 版本的应用程序之间共享资源。
第五章, 跨平台音频流,从使用 OpenAL 库组织音频流开始。这之后,我们继续学习 RIFF WAVE 文件格式的读取,以及 OGG Vorbis 流的解码。最后,我们学习如何使用 libmodplug 播放一些追踪音乐。最近的 Android NDK 包括了 OpenSL ES API 的实现。然而,我们正在寻找一个完全可移植的实现,以便在桌面 PC 和其他移动平台之间实现无缝的游戏调试功能。为此,我们将 OpenAL 实现预编译成一个静态库,然后在 libogg 和 libvorbis 之上组织一个小型的多线程声音流库。
第六章,统一 OpenGL ES 3 和 OpenGL 3,介绍了桌面 OpenGL 3 和移动 OpenGL ES 3.0 的基本渲染循环。将应用程序重新部署到移动设备是一项耗时的操作,这阻止了开发者进行快速的功能测试和调试。为了允许在 PC 上开发游戏逻辑并进行调试,我们提供了一种技术,可以在移动 OpenGL ES 中使用桌面 GLSL 着色器。
第七章,跨平台 UI 和输入系统,将教你如何以可移植的方式实现多触摸事件处理和手势识别。如今,移动设备几乎与基于手势的触摸输入同义。没有图形用户界面(GUI)的现代面向用户的应用程序是无法存在的。组织交互有两个基本问题:输入和文本渲染。为了便于测试和调试,我们还展示了如何在配备了多个鼠标设备的 Windows 7 PC 上模拟多触摸输入。由于我们的目标是开发交互式游戏应用,我们必须以熟悉的方式实现用户输入。我们将系统地教你如何创建一个屏幕上的游戏手柄 UI。在一个全球多元文化环境中,任何应用程序拥有一个多语言文本渲染器是非常理想的。我们将展示如何使用 FreeType 库来渲染拉丁文、西里尔文和从左到右的文本。将介绍一个基于字典的方法来组织多语言 UTF-8 本地化界面。
第八章,编写消除游戏,将把我们介绍的所有技术整合在一起,编写一个简单的消除游戏,包括使用 OpenGL ES 进行渲染,处理输入,资源打包,以及 PC 端的调试。该游戏也可以在 Windows 桌面 PC 上运行和调试,并且可以轻松地移植到其他移动平台。
第九章,编写拼图游戏,将提供一个更复杂的示例,整合上述所有内容。关于图形和输入的所有上述元素都将使用本地网络库和 API 从 Picasa 在线服务下载图片。
本书中所需准备
本书以 Windows PC 为中心。由于模拟器在 3D 图形和原生音频方面的限制,建议使用 Android 智能手机或平板电脑。
注意
本书中的源代码基于开源的 Linderdaum 引擎,并提炼了该引擎中使用的一些方法和技巧。你可以访问www.linderdaum.com
获取。
假设读者具备 C 或 C++的基础知识,包括指针操作、多线程和基本的面向对象编程概念。读者还应熟悉高级编程概念,如线程和同步原语,并对 GCC 工具链有基本的了解。我们还希望读者不害怕在没有 IDE(是的,在没有自动补全功能的 IDE 中开发绝对是一项技能)的情况下,例如从终端/FarManager/Notepad/SublimeText 进行开发。
本书不涉及 Android Java 开发。你需要阅读其他资料来熟悉这方面的内容。
对线性代数和 3D 空间中的仿射变换有一些实际了解对于理解 OpenGL 编程和手势识别很有帮助。
这本书适合谁
您想要将现有的 C/C++应用程序移植到 Android 吗?您是一位有经验的 C++开发者,想要跳入现代移动开发吗?您想要提高基于 Java 的 Android 应用程序的性能吗?您想在您的 Android 应用程序中使用 C++编写的优秀库吗?您想通过在 PC 上调试移动游戏来提高您的生产力吗?
如果您对这些问题中的任何一个回答“是”,那么这本书就是为您准备的。
构建源代码
本书的代码包中的示例可以使用以下命令进行编译:
-
对于 Windows:make all
-
对于 Android:ndk-buildant copy-common-media debug
约定
在这本书中,您会发现多种文本样式,这些样式用于区分不同类型的信息。以下是一些样式示例,以及它们含义的解释。
文本中的代码字会像这样显示:"JAVA_HOME
变量应指向 Java 开发工具包文件夹。"
代码块如下排版:
package com.packtpub.ndkcookbook.app1;
import android.app.Activity;
public class App1Activity extends Activity
{
};
当我们希望引起您对某行代码的注意时,相关的行会像这样被强调:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">App1</string>
</resources>
所有的命令行输入或输出都如下书写:
>adb.exe logcat -v time > 1.txt
新术语和重要词汇会用粗体显示。您在屏幕上看到的词,例如菜单或对话框中的,会像这样出现在文本中:"选择是否安装这个设备软件,您应该点击安装按钮"。
注意
警告或重要说明会像这样出现在一个框里。
提示
提示和技巧会像这样出现。
读者反馈
我们始终欢迎读者的反馈。让我们知道您对这本书的看法——您喜欢或可能不喜欢的内容。读者的反馈对我们开发您真正能从中获得最大收益的标题非常重要。
如果您想要给我们发送一般性的反馈,只需发送电子邮件至<feedback@packtpub.com>
,并在邮件的主题中提及书名。
如果您在某个主题上有专业知识,并且您有兴趣撰写或为书籍做贡献,请查看我们在www.packtpub.com/authors上的作者指南。
客户支持
既然您现在是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您充分利用您的购买。
下载本书的示例代码
您可以从您的账户下载您购买的所有 Packt 图书的示例源代码文件,网址是www.PacktPub.com
。如果您在其他地方购买了这本书,可以访问www.PacktPub.com/support
注册,我们会直接将文件通过电子邮件发送给您。我们努力为这本书编写和调试源代码。事实上,在现实生活中,代码中总是潜伏着 bug,需要在发布后修复。
我们建立了一个 GitHub 仓库,这样每个人都可以下载最新的源代码包,并通过提交 pull 请求来提交错误修复和改进。该仓库可以从以下位置克隆:github.com/corporateshark/Android-NDK-Game-Development-Cookbook
。我们源代码包的最新快照可以在以下链接获取:www.linderdaum.com/Android-NDK-Game-Development-Cookbook-SourceCodeBungle.zip
。
错误更正
尽管我们已经尽力确保内容的准确性,但错误仍然会发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——我们非常感激你能向我们报告。这样做,你可以让其他读者免受挫折,并帮助我们改进本书的后续版本。如果你发现任何错误,请通过访问 www.packtpub.com/support
报告,选择你的书,点击错误更正提交表单链接,并输入错误详情。一旦你的错误更正被验证,你的提交将被接受,错误更正将在我们网站的相应标题下的错误更正部分上传或添加到现有错误列表中。任何现有的错误更正可以通过从 www.packtpub.com/support
选择你的标题来查看。
盗版
在互联网上,版权材料的盗版是一个所有媒体都面临的持续问题。在 Packt,我们非常重视保护我们的版权和许可。如果你在互联网上以任何形式发现我们作品非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请在提供疑似盗版材料链接的情况下,联系我们 <copyright@packtpub.com>
。
我们感谢您保护我们的作者,以及我们为您带来有价值内容的能力。
问题
如果你在这本书的任何方面遇到问题,可以联系我们 <questions@packtpub.com>
,我们将尽力解决。
第一章:建立构建环境
*一些 LinkedIn 个人资料说使用特定 IDE 进行开发是一种技能。不!不使用任何 IDE 进行开发才是真正的技能! | ||
---|---|---|
--谢尔盖·科萨列夫斯基 |
在本章中,我们将涵盖以下内容:
-
在 Windows 上安装 Android 开发工具
-
在 Linux 上安装 Android 开发工具
-
手动创建应用程序模板
-
向你的应用程序添加本地 C++代码
-
切换 NDK 工具链
-
支持多种 CPU 架构
-
使用 OpenGL ES 进行基本渲染
-
跨平台开发
-
统一跨平台代码
-
链接与源代码组织
-
签名发布 Android 应用程序
引言
本章介绍如何在 Microsoft Windows 或 Ubuntu/Debian Linux 上安装和配置 Android NDK,以及如何在基于 Android 的设备上构建和运行你的第一个应用程序。我们将学习如何设置不同的编译器和随 Android NDK 提供的工具链。此外,我们还将展示如何设置 Windows 上的 GCC 工具链以构建你的项目。本章的其余部分致力于使用 C++进行跨平台开发。
在 Windows 上安装 Android 开发工具
要开始为 Android 开发游戏,你需要在系统上安装一些基本工具。
准备就绪
以下是开始为 Android 开发游戏所需的所有先决条件列表:
-
Android SDK 位于
developer.android.com/sdk/index.html
。注意
本书基于 Android SDK 修订版 22.3,并使用 Android API Level 19 进行测试。
-
Android NDK 位于
developer.android.com/tools/sdk/ndk/index.html
(我们使用的是 Android NDK r9b)。 -
Apache Ant 位于
ant.apache.org
。这是一个 Java 命令行工具,C++开发者可能不太熟悉。它的目的是构建 Java 应用程序,由于每个 Android 应用程序都有一个 Java 包装器,因此此工具将帮助我们打包成部署就绪的存档(这些被称为.apk
包,代表Android Package)。 -
Java SE 开发工具包位于
www.oracle.com/technetwork/java/javase/downloads/index.html
。
早期版本的 Windows SDK/NDK 需要安装Cygwin环境,这是一种类似于 Linux 的 Windows 环境。最新版本的这些工具可以在 Windows 上本机运行,无需任何中间层。我们将重点介绍无 Cygwin 环境,并且将在不使用 IDE 的情况下进行所有开发。你没听错,我们将仅使用命令行。本书中的所有示例都是在 Windows PC 上编写和调试的。
要编译本书中介绍的本地 Windows 应用程序,你需要一个像样的 C++编译器,例如带有 GCC 工具链的 MinGW 包。使用 Microsoft Visual Studio 也是可行的。
注意
Windows 的最小化 GNU(MinGW)是一个使用GNU 编译器集合(GCC)端口的 Windows 应用程序的最小开发环境。
如何操作...
-
Android SDK 和 NDK 应安装到名称中不包含任何空格的文件夹中。
注意
这个要求源于 Android SDK 中脚本的限制。StackOverflow 上有一个很好的讨论,解释了这些限制背后的部分原因,请见
stackoverflow.com/q/6603194/1065190
。 -
其他工具可以安装到它们的默认位置。我们在 Windows 7 系统上使用了以下路径:
工具 | 路径 |
---|---|
Android SDK | D:\android-sdk-windows |
Android NDK | D:\ndk |
Apache Ant | D:\ant |
Java 开发工具包 | C:\Program Files\Java\jdk1.6.0_33 |
所有工具都有相当不错的 GUI 安装程序(请看以下图片,展示了 SDK R21 的 Android SDK 管理器),所以你不必使用命令行。
对于 Windows 环境,你需要 MinGW GCC 工具链。易于安装的一体化软件包可以在www.equation.com
的编程工具部分,Fortran, C, C++子部分找到。或者,你也可以从www.mingw.org
下载官方安装程序。我们将使用来自www.equation.com的版本。
还有更多内容...
你需要设置一些环境变量,让工具知道文件的位置。JAVA_HOME
变量应指向 Java 开发工具包文件夹。NDK_HOME
变量应指向 Android NDK 安装文件夹,而ANDROID_HOME
应指向 Android SDK 文件夹(注意双反斜杠)。我们使用了以下环境变量值:
JAVA_HOME=D:\Java\jdk1.6.0_23
NDK_HOME=D:\ndk
ANDROID_HOME=D:\\android-sdk-windows
最终配置类似于以下截图所示,展示了 Windows 的环境变量对话框:
安装 MinGW 成功后,你还需要将其安装文件夹中的bin
文件夹添加到PATH
环境变量中。例如,如果 MinGW 安装在C:\MinGW
,那么PATH
应该包含C:\MinGW\bin
文件夹。
在 Linux 上安装 Android 开发工具
在 Linux 上安装基本工具与在 Windows 上一样简单。在本教程中,我们将看到如何在*nix 系统上安装基本的 Android 开发工具。
准备就绪
我们假设你已经有一个带有apt
包管理器的 Ubuntu/Debian 系统。详情请参考wiki.debian.org/Apt
。
如何操作...
执行以下步骤来安装所需的基本工具:
-
通过运行以下命令,确保你为你的操作系统使用了最新版本的软件包:
>sudo apt-get update
-
安装 OpenJDK 6+:
>sudo apt-get install openjdk-6-jdk
-
安装 Apache Ant 构建自动化工具:
>sudo apt-get install ant
-
从
developer.android.com
下载官方的 Android SDK。旁边有一个更大的包,其中包含 Eclipse IDE 的 ADT 插件。然而,由于我们所有的开发都是通过命令行进行的,所以我们不需要它。运行以下命令:>wget http://dl.google.com/android/android-sdk_r22.2.1-linux.tgz
-
解压下载的
.tgz
文件(实际版本可能有所不同,截至 2013 年 10 月,22.2.1 是最新版本):>tar -xvf android-sdk_r22.2.1-linux.tgz
-
使用
~/<sdk>/tools/android
安装最新的 Platform Tools 和所有 SDKs——就像在 Windows 情况下一样。如果不这样做,在尝试使用 Ant 工具构建任何 Android 应用程序时将出现错误。
-
从
developer.android.com
获取官方的 Android NDK:>wget http://dl.google.com/android/ndk/android-ndk-r9b-linux-x86_64.tar.bz2
-
解压下载的 NDK
.tgz
文件:>tar -xvf android-ndk-r9b-linux-x86_64.tar.bz2
-
将
NDK_ROOT
环境变量设置为你的 Android NDK 目录(例如,在我们的情况下是~/android-ndk-r9b
):>NDK_ROOT=/path/to/ndk
如果这些设置适用于系统的所有用户,将这行和
JAVA_HOME
的定义放到/etc/profile
或/etc/environment
中是有用的。 -
如果你运行的是 64 位系统,你必须确保你也安装了 32 位的 Java 运行时。
-
运行以下命令以安装库。如果不这样做可能会导致
adb
和aapt
工具出现错误:>sudo apt-get install ia32-libs
还有更多...
有一个很好的单行脚本可以帮助你自动检测 OpenJDK 的主目录。它本质上解析了/usr/bin/javac
链接到完整路径,并返回路径的目录部分。
JAVA_HOME=$(readlink -f /usr/bin/javac | sed "s:bin/javac::")
手动创建应用程序模板
首先,我们将为我们的应用程序创建一个基本模板。通过 Android SDK 构建的每个 Android 应用程序都应该包含预定义的目录结构和配置.xml
文件。这可以使用 Android SDK 工具和 IDE 完成。在本教程中,我们将学习如何手动完成。我们稍后会把这些文件作为所有示例的起点。
准备工作
让我们设置项目的目录结构(见下截图):
这是一般 Android 项目的典型结构。我们将手动创建所有必需的文件,而不是使用 Android 工具。
如何操作...
将 Java Activity
代码放入App1\src\com\packtpub\ndkcookbook\app1\App1Activity.java
文件中,其内容应如下所示:
package com.packtpub.ndkcookbook.app1;
import android.app.Activity;
public class App1Activity extends Activity
{
};
可本地化的应用程序名称应放入App1\res\values\strings.xml
。在AndroidManifest.xml
文件中,字符串参数app_name
用于指定我们应用程序的用户可读名称,如下代码所示:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">App1</string>
</resources>
现在我们需要为 Apache Ant 和 Android SDK 构建系统编写更多脚本。它们是构建应用程序的.apk
包所必需的。
-
下面是
App1/project.properties
文件:target=android-15 sdk.dir=d:/android-sdk-windows
-
我们还需要为 Ant 准备两个文件。以下是
App1/AndroidManifest.xml
:<?xml version="1.0" encoding="utf-8"?> <manifest package="com.packtpub.ndkcookbook.app1" android:versionCode="1" android:versionName="1.0.0"> <supports-screens android:smallScreens="false" android:normalScreens="true" android:largeScreens="true" android:xlargeScreens="true" android:anyDensity="true" /> <uses-sdk android:minSdkVersion="8" /> <uses-sdk android:targetSdkVersion="18" />
我们的示例至少需要 OpenGL ES 2。让 Android 知道这一点:
<uses-feature android:glEsVersion="0x00020000"/> <application android:label="@string/app_name" android:icon="@drawable/icon" android:installLocation="preferExternal" android:largeHeap="true" android:debuggable="false"> <activity android:name="com.packtpub.ndkcookbook.app1.App1Activity" android:launchMode="singleTask"
创建一个横屏方向的全屏应用程序:
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" android:screenOrientation="landscape" android:configChanges="orientation|keyboardHidden" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
第二个文件是
App1/build.xml
:<?xml version="1.0" encoding="UTF-8"?> <project name="App1" default="help"> <property file="ant.properties" /> <loadproperties srcFile="project.properties" /> <import file="${sdk.dir}/tools/ant/build.xml" /> </project>
工作原理...
将所有列出的文件就位后,我们现在可以构建项目并将其安装在 Android 设备上,具体步骤如下:
-
从
App1
文件夹运行:>ant debug
-
之前命令输出的末尾应如下所示:
BUILD SUCCESSFUL Total time: 12 seconds
-
构建的调试
.apk
包位于bin/App1-debug.apk
。 -
要安装应用,请运行:
>adb install App1-debug.apk
注意
在运行此命令之前,不要忘记通过 USB 连接设备并在 Android 设置中打开 USB 调试。
-
您应该看到来自
adb
的输出,类似于以下命令:* daemon not running. starting it now on port 5037 * * daemon started successfully * 1256 KB/s (8795 bytes in 0.006s) pkg: /data/local/tmp/App1-debug.apk Success
应用程序现在可以从您的 Android 启动器(名为App1
)启动。您将看到一个黑色屏幕。您可以使用返回按钮退出应用程序。
还有更多...
不要忘记将应用图标放入App1\res\drawable\icon.png
。如果您想快速构建应用程序,可以参考本书的代码包,或者放置自己的图标。72 x 72 32 位即可。您可以在developer.android.com/design/style/iconography.html
找到官方的 Android 图标指南。
关于AndroidManifest.xml
文件的官方文档可以在developer.android.com/guide/topics/manifest/manifest-intro.html
找到。
此外,您可以使用以下方式通过adb -r
命令行开关更新应用程序,而无需卸载之前的版本:
>adb install -r App1-debug.apk
否则,在安装应用程序的新版本之前,您必须使用以下命令卸载现有版本:
>adb uninstall <package-name>
另请参阅...
- 签名发布 Android 应用程序
向您的应用程序添加本地 C++代码
让我们扩展之前食谱中讨论的最小化 Java 模板,以便为我们的本地 C++代码创建一个占位符。
准备就绪
我们需要将App1
项目中的所有文件复制过来,以便在创建初始项目文件时节省时间。这个食谱将重点介绍需要修改App1
项目以添加 C++代码的内容。
如何操作...
执行以下步骤为我们的 C++代码创建占位符:
-
添加包含以下代码的
jni/Wrappers.cpp
文件:#include <stdlib.h> #include <jni.h> #include <android/log.h> #define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "App2", __VA_ARGS__)) extern "C" { JNIEXPORT void JNICALL Java_com_packtpub_ndkcookbook_app2_App2Activity_onCreateNative( JNIEnv* env, jobject obj ) { LOGI( "Hello World!" ); } }
-
我们需要修改前一个食谱中的
Activity
类,以便通过以下代码利用我们在上一节中添加的本机代码:package com.packtpub.ndkcookbook.app2; import android.app.Activity; import android.os.Bundle; public class App2Activity extends Activity { static {
在这里我们加载名为
libApp2.so
的本机库。注意省略的lib
前缀和.so
扩展名:System.loadLibrary( "App2" ); } @Override protected void onCreate( Bundle icicle ) { super.onCreate( icicle ); onCreateNative(); } public static native void onCreateNative(); };
-
告诉 NDK 构建系统如何处理
.cpp
文件。创建jni/Android.mk
文件。Android.mk
文件由 Android NDK 构建系统使用,以了解如何处理项目的源代码:TARGET_PLATFORM := android-7 LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_ARM_MODE := arm LOCAL_MODULE := App2 LOCAL_SRC_FILES += Wrappers.cpp LOCAL_ARM_MODE := arm COMMON_CFLAGS := -Werror -DANDROID -DDISABLE_IMPORTGL \ -isystem $(SYSROOT)/usr/include/ ifeq ($(TARGET_ARCH),x86) LOCAL_CFLAGS := $(COMMON_CFLAGS) else LOCAL_CFLAGS := -mfpu=vfp -mfloat-abi=softfp \ -fno-short-enums $(COMMON_CFLAGS) endif LOCAL_LDLIBS := -llog -lGLESv2 -Wl,-s LOCAL_CPPFLAGS += -std=gnu++0x include $(BUILD_SHARED_LIBRARY)
注意
ifeq ($(TARGET_ARCH),x86)
部分。在这里,我们为 ARMv7 上的浮点支持指定了架构特定的编译器标志。这将在 ARM 架构上为您提供硬件浮点支持,并在 x86 Android 目标架构上提供无警告的日志。 -
将以下代码粘贴到
jni/Application.mk
文件中:APP_OPTIM := release APP_PLATFORM := android-7 APP_STL := gnustl_static APP_CPPFLAGS += -frtti APP_CPPFLAGS += -fexceptions APP_CPPFLAGS += -DANDROID APP_ABI := armeabi-v7a APP_MODULES := App2 NDK_TOOLCHAIN_VERSION := clang
工作原理...
-
首先,我们需要编译本地代码。从
App2
项目的根目录运行以下命令:>ndk-build
-
您应该看到以下输出:
Compile++ arm: App2 <= Wrappers.cpp SharedLibrary: libApp2.so Install : libApp2.so => libs/armeabi-v7a/libApp2.so
-
现在,像上一个食谱一样,通过运行以下命令开始创建
.apk
:>ant debug
-
您的
libApp2.so
本地共享库将被打包进App2-debug.apk
文件中。安装并运行它,它将在设备日志中输出Hello World!
字符串。
还有更多...
您可以使用 adb
命令查看设备日志。使用以下命令可以创建一个带有时间戳的整洁格式化日志:
>adb logcat -v time > 1.txt
从您的设备实际输出的内容将类似于以下命令:
05-22 13:00:13.861 I/App2 ( 2310): Hello World!
切换 NDK 工具链
工具链是一组用于构建项目的工具。工具链通常包括编译器、汇编器和链接器。Android NDK 提供了不同版本的 GCC 和 Clang 不同的工具链。它有一种方便简单的方式来切换它们。
准备就绪
在继续操作之前,请查看可用的工具链列表。您可以在 $(NDK_ROOT)/toolchains/
文件夹中找到所有可用的工具链。
如何操作...
Application.mk
中的参数 NDK_TOOLCHAIN_VERSION
对应于可用的工具链之一。在 NDK r9b 中,您可以在三个 GCC 版本之间切换—4.6 和 4.7(已被标记为不推荐使用,并将在下一个 NDK 版本中移除),以及 4.8。还有两个 Clang 版本—Clang3.2(也已标记为不推荐使用)和 Clang3.3。NDK r9b 中的默认工具链仍然是 GCC 4.6。
从 NDK r8e 开始,您只需将 clang
指定为 NDK_TOOLCHAIN_VERSION
的值。此选项将选择可用的最新 Clang 工具链版本。
还有更多...
工具链是由 $(NDK_ROOT)/build/core/init.mk
脚本发现的,因此您可以在名为 <ABI>-<ToolchainName>
的文件夹中定义自己的工具链,并在 Application.mk
中使用它。
支持多种 CPU 架构
Android NDK 支持不同的 CPU 架构,例如基于 ARMv5TE 和 ARMv7 的设备、x86 和 MIPS(大端架构)。我们可以创建能在任何支持平台上运行的胖二进制文件。
准备就绪
查找基于 Android 的设备的架构。您可以使用以下 adb
命令进行操作:
>adb shell cat /proc/cpuinfo
如何操作...
以下是选择适当 CPU 架构集的两种方法:
-
默认情况下,NDK 将为基于 ARMv5TE 的 CPU 生成代码。在
Application.mk
中使用参数APP_ABI
选择不同的架构,例如(从以下列表中选择一行):APP_ABI := armeabi-v7a APP_ABI := x86 APP_ABI := mips
-
我们可以指定多个架构,通过以下命令创建一个胖二进制文件,以便在任何架构上运行:
APP_ABI := armeabi armeabi-v7a x86 mips
还有更多内容...
胖二进制的主要缺点是生成的.apk
大小,因为为每个指定的架构编译了单独的本地代码版本。如果你的应用程序大量使用第三方库,那么包大小可能会成为问题。请明智地规划你的交付物。
使用 OpenGL ES 的基本渲染
让我们为示例 Android 应用程序App2
添加一些图形。在这里,我们展示了如何创建一个离屏位图,然后使用你 Android 设备上可用的 OpenGL ES 版本 2 或 3 将其复制到屏幕上。
注意
有关完整源代码,请参考书中可下载代码包中的App3
示例。
准备工作
我们假设读者对 OpenGL 和GL 着色语言(GLSL)有一定的了解。有关桌面 OpenGL 的文档,请参考www.opengl.org/documentation
,有关移动 OpenGL ES 的文档,请参考www.khronos.org/opengles
。
如何操作...
-
我们需要编写一个简单的顶点和片段 GLSL 着色器,它将使用 OpenGL ES 在屏幕上渲染我们的帧缓冲区。我们将它们直接作为字符串放入
jni/Wrappers.cpp
中。以下代码显示了顶点着色器:static const char g_vShaderStr[] = "#version 100\n" "precision highp float;\n" "attribute vec3 vPosition;\n" "attribute vec3 vCoords;\n" "varying vec2 Coords;\n" "void main()\n" "{\n" " Coords = vCoords.xy;\n" " gl_Position = vec4( vPosition, 1.0 );\n" "}\n";
-
片段着色器如下:
static const char g_fShaderStr[] = "#version 100\n" "precision highp float;\n" "varying vec2 Coords;\n" "uniform sampler2D Texture0;\n" "void main()\n" "{\n" " gl_FragColor = texture2D( Texture0, Coords );\n" "}\n";
-
我们还需要以下帮助函数来将着色器加载到 OpenGL ES 中:
static GLuint LoadShader( GLenum type, const char* shaderSrc ) { GLuint shader = glCreateShader( type ); glShaderSource ( shader, 1, &shaderSrc, NULL ); glCompileShader ( shader ); GLint compiled; glGetShaderiv ( shader, GL_COMPILE_STATUS, &compiled ); GLsizei MaxLength = 0; glGetShaderiv( shader, GL_INFO_LOG_LENGTH, &MaxLength ); char* InfoLog = new char[MaxLength]; glGetShaderInfoLog( shader, MaxLength, &MaxLength, InfoLog ); LOGI( "Shader info log: %s\n", InfoLog ); return shader; }
工作原理...
在这里,我们不会详细介绍 OpenGL ES 编程的所有细节,而是专注于一个最小的应用程序(App3
),它应该在 Java 中初始化GLView
;创建片段和顶点程序,创建并填充由两个三角形组成的单一四边形的顶点数组,然后用纹理渲染它们,该纹理是从g_FrameBuffer
内容更新的。就是这样——只需绘制离屏帧缓冲区。以下代码展示了用离屏缓冲区内容绘制全屏四边形的纹理:
const GLfloat vVertices[] = { -1.0f, -1.0f, 0.0f,
-1.0f, 1.0f, 0.0f,
1.0f, -1.0f, 0.0f,
-1.0f, 1.0f, 0.0f,
1.0f, -1.0f, 0.0f,
1.0f, 1.0f, 0.0f
};
const GLfloat vCoords[] = { 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f,
1.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f,
1.0f, 0.0f, 0.0f,
1.0f, 1.0f, 0.0f
};
glUseProgram ( g_ProgramObject );
这些属性变量在顶点着色器中声明。请参考前面代码中的g_vShaderStr[]
的值。
GLint Loc1 = glGetAttribLocation(g_ProgramObject,"vPosition");
GLint Loc2 = glGetAttribLocation(g_ProgramObject,"vCoords");
glBindBuffer( GL_ARRAY_BUFFER, 0 );
glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, 0 );
glVertexAttribPointer(
Loc1, 3, GL_FLOAT, GL_FALSE, 0, vVertices );
glVertexAttribPointer(
Loc2, 3, GL_FLOAT, GL_FALSE, 0, vCoords );
glEnableVertexAttribArray( Loc1 );
glEnableVertexAttribArray( Loc2 );
glDisable( GL_DEPTH_TEST );
glDrawArrays( GL_TRIANGLES, 0, 6 );
glUseProgram( 0 );
glDisableVertexAttribArray( Loc1 );
glDisableVertexAttribArray( Loc2 );
我们还需要一些 JNI 回调。第一个处理表面大小变化,如下代码所示:
JNIEXPORT void JNICALLJava_com_packtpub_ndkcookbook_app3_App3Activity_SetSurfaceSize(JNIEnv* env, jclass clazz, int Width, int Height )
{
LOGI( "SurfaceSize: %i x %i", Width, Height );
g_Width = Width;
g_Height = Height;
GLDebug_LoadStaticProgramObject();
glGenTextures( 1, &g_Texture );
glBindTexture( GL_TEXTURE_2D, g_Texture );
通过以下代码禁用纹理映射:
glTexParameteri( GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER, GL_NEAREST );
glTexImage2D( GL_TEXTURE_2D, 0, GL_RGBA,ImageWidth, ImageHeight, 0, GL_RGBA,GL_UNSIGNED_BYTE, g_FrameBuffer );
}
第二个回调实际执行帧渲染:
JNIEXPORT void JNICALL Java_com_packtpub_ndkcookbook_app3_App3Activity_DrawFrame( JNIEnv* env, jobject obj )
{
通过以下代码调用我们的帧渲染回调:
OnDrawFrame();
glActiveTexture( GL_TEXTURE0 );
glBindTexture( GL_TEXTURE_2D, g_Texture );
glTexSubImage2D( GL_TEXTURE_2D, 0, 0, 0,ImageWidth, ImageHeight, GL_RGBA,GL_UNSIGNED_BYTE, g_FrameBuffer );
GLDebug_RenderTriangle();
}
跨平台开发
主要思想是在 What You See (在 PC 上) is What You Get (在设备上) 的跨平台开发可能性,当大部分应用程序逻辑可以在像 Windows 这样的熟悉桌面环境中开发,并且必要时可以使用 NDK 为 Android 构建。
准备工作
要实现我们刚才讨论的内容,我们必须在 NDK、POSIX 和 Windows API 之上实现某种抽象。这种抽象至少应该具备以下特点:
-
能够在屏幕上渲染缓冲区内容:我们的框架应该提供函数,将离屏 framebuffer(一个 2D 像素数组)的内容构建到屏幕上(对于 Windows,我们将窗口称为“屏幕”)。
-
事件处理:框架必须能够处理多点触控输入以及虚拟/物理按键按下(一些 Android 设备,如东芝 AC 100,或者 Ouya 游戏机以及其他游戏设备具有物理按钮),定时事件以及异步操作完成。
-
文件系统、网络和音频播放:这些实体的抽象层需要你完成大量工作,因此实现在第三章,网络编程,第四章,组织虚拟文件系统,以及第五章,跨平台音频流中介绍。
如何进行...
-
让我们继续为 Windows 环境编写一个最小应用程序,因为我们已经有了 Android 的应用程序(例如,
App1
)。一个最小化的 Windows GUI 应用程序是指创建单一窗口并启动事件循环的应用程序(见以下Win_Min1/main.c
中的示例):#include <windows.h> LRESULT CALLBACK MyFunc(HWND h, UINT msg, WPARAM w, LPARAM p) { if(msg == WM_DESTROY) { PostQuitMessage(0); } return DefWindowProc(h, msg, w, p); } char WinName[] = "MyWin";
-
入口点与 Android 不同。但其目的依然不变——初始化表面渲染并调用回调:
int main() { OnStart(); const char WinName[] = "MyWin"; WNDCLASS wcl; memset( &wcl, 0, sizeof( WNDCLASS ) ); wcl.lpszClassName = WinName; wcl.lpfnWndProc = MyFunc; wcl.hCursor = LoadCursor( NULL, IDC_ARROW ); if ( !RegisterClass( &wcl ) ) { return 0; } RECT Rect; Rect.left = 0; Rect.top = 0;
-
窗口客户区的尺寸预定义为
ImageWidth
和ImageHeight
常量。然而,WinAPI 函数CreateWindowA()
接受的并非客户区的尺寸,而是包括标题栏、边框和其他装饰的窗口尺寸。我们需要通过以下代码调整窗口矩形,以将客户区设置为期望的尺寸:Rect.right = ImageWidth; Rect.bottom = ImageHeight; DWORD dwStyle = WS_OVERLAPPEDWINDOW; AdjustWindowRect( &Rect, dwStyle, false ); int WinWidth = Rect.right - Rect.left; int WinHeight = Rect.bottom - Rect.top; HWND hWnd = CreateWindowA( WinName, "App3", dwStyle,100, 100, WinWidth, WinHeight,0, NULL, NULL, NULL ); ShowWindow( hWnd, SW_SHOW ); HDC dc = GetDC( hWnd );
-
通过以下代码创建离屏设备上下文和位图,该位图保存我们的离屏 framebuffer:
hMemDC = CreateCompatibleDC( dc ); hTmpBmp = CreateCompatibleBitmap( dc,ImageWidth, ImageHeight ); memset( &BitmapInfo.bmiHeader, 0,sizeof( BITMAPINFOHEADER ) ); BitmapInfo.bmiHeader.biSize = sizeof( BITMAPINFOHEADER ); BitmapInfo.bmiHeader.biWidth = ImageWidth; BitmapInfo.bmiHeader.biHeight = ImageHeight; BitmapInfo.bmiHeader.biPlanes = 1; BitmapInfo.bmiHeader.biBitCount = 32; BitmapInfo.bmiHeader.biSizeImage = ImageWidth*ImageHeight*4; UpdateWindow( hWnd );
-
创建应用程序窗口后,我们必须运行一个典型的消息循环:
MSG msg; while ( GetMessage( &msg, NULL, 0, 0 ) ) { TranslateMessage( &msg ); DispatchMessage( &msg ); } … }
-
这个程序只处理窗口销毁事件,并不渲染任何内容。编译此程序只需以下单一命令:
>gcc -o main.exe main.c -lgdi32
它是如何工作的...
要在屏幕上渲染一个 framebuffer,我们需要创建一个所谓的设备上下文以及相关的位图,并在窗口函数中添加WM_PAINT
事件处理程序。
为了处理键盘和鼠标事件,我们在之前程序的switch
语句中添加了WM_KEYUP
和WM_MOUSEMOVE
的情况。实际的事件处理在外部提供的例程OnKeyUp()
和OnMouseMove()
中执行,这些例程包含了我们的游戏逻辑。
以下是程序完整的源代码(省略的部分与之前的示例相似)。函数OnMouseMove()
、OnMouseDown()
和OnMouseUp()
接受两个整数参数,用于存储鼠标指针的当前坐标。函数OnKeyUp()
和OnKeyDown()
接受一个参数——按下的(或释放的)键码:
#include <windows.h>
HDC hMemDC;
HBITMAP hTmpBmp;
BITMAPINFO BmpInfo;
在以下代码中,我们存储全局 RGBA 帧缓冲区:
unsigned char* g_FrameBuffer;
我们在这个回调中完成所有与操作系统无关的帧渲染。我们绘制一个简单的 XOR 图案(lodev.org/cgtutor/xortexture.html
)到帧缓冲区中,如下所示:
void DrawFrame()
{
int x, y;
for (y = 0 ; y < ImageHeight ; y++)
{
for (x = 0 ; x < ImageWidth ; x++)
{
int Ofs = y * ImageWidth + x;
int c = (x ^ y) & 0xFF;
int RGB = (c<<16) | (c<<8) | (c<<0) | 0xFF000000;
( ( unsigned int* )g_FrameBuffer )[ Ofs ] = RGB;
}
}
}
以下代码展示了WinAPI
窗口函数:
LRESULT CALLBACK MyFunc(HWND h, UINT msg, WPARAM w, LPARAM p)
{
PAINTSTRUCT ps;
switch(msg)
{
case WM_DESTROY:
PostQuitMessage(0);
break;
case WM_KEYUP:
OnKeyUp(w);
break;
case WM_KEYDOWN:
OnKeyDown(w);
break;
case WM_LBUTTONDOWN:
SetCapture(h);
OnMouseDown(x, y);
break;
case WM_MOUSEMOVE:
OnMouseMove(x, y);
break;
case WM_LBUTTONUP:
OnMouseUp(x, y);
ReleaseCapture();
break;
case WM_PAINT:
dc = BeginPaint(h, &ps);
DrawFrame();
通过以下代码将g_FrameBuffer
传输到位图:
SetDIBits(hMemDC, hTmpBmp, 0, Height,g_FrameBuffer, &BmpInfo, DIB_RGB_COLORS);
SelectObject(hMemDC, hTmpBmp);
并通过以下代码将其复制到窗口表面:
BitBlt(dc, 0, 0, Width, Height, hMemDC, 0, 0, SRCCOPY);
EndPaint(h, &ps);
break;
}
return DefWindowProc(h, msg, w, p);
}
由于我们的项目包含一个 make 文件,因此可以通过单个命令完成编译:
>make all
运行此程序应产生如下截图所示的结果,显示了在 Windows 上运行的Win_Min2示例:
还有更多…
安卓和 Windows 对主循环的实现主要区别可以概括如下。在 Windows 中,我们控制主循环。我们声明一个循环,从系统中获取消息,处理输入,更新游戏状态,并渲染帧(在以下图中以绿色标记)。每个阶段调用我们可移植游戏中的适当回调(以下图中以蓝色表示)。相反,安卓部分的工作方式完全不同。主循环从本地代码中移出,并存在于Java Activity和GLSurfaceView类中。它调用我们在封装本地库中实现的 JNI 回调(以下图中以红色显示)。本地封装器调用我们的可移植游戏回调。以下是这样总结的:
书的其余部分以此类架构为中心,游戏功能将在这些可移植On...()回调中实现。
还有一个重要的注意事项。对定时器事件做出响应以创建动画,在 Windows 上可以通过SetTimer()
调用和WM_TIMER
消息处理程序来完成。我们在第二章,移植公共库中讨论刚体物理模拟时会涉及到这一点。然而,组织一个固定时间步长主循环会更好,这在本书的后面会解释。
另请参阅
-
第六章,统一 OpenGL ES 3 和 OpenGL 3
-
第八章,编写消除类游戏中的食谱实现主循环
统一跨平台代码
现在,我们有一个简单程序的两个不同版本(Win_Min2
和App3
)。让我们看看如何统一代码的公共部分。
准备就绪
在 Android 中,应用程序初始化阶段是不同的,由于我们采用了混合 Java 和 C++的方法,入口点也会有所不同。在 C++中,我们依赖于int main()
或DWORD WinMain()
函数;而在 Android 中,我们可以从 Java 启动代码中调用我们选择的 JNI 函数。事件处理和初始化代码的渲染也有很大差异。为此,我们使用预处理器定义标记代码部分,并将不同操作系统的代码放入不同的文件中——Wrappers_Android.h
和Wrappers_Windows.h
。
如何操作...
我们使用标准宏来检测程序正在编译的目标操作系统:针对 Windows 的编译器提供_WIN32
符号定义,而任何基于 Linux 的操作系统(包括 Android)都会定义__linux__
宏。然而,__linux__
的定义还不够,因为 Android 中缺少一些 API。ANDROID
是一个非标准宏,我们向编译器传递-DANDROID
开关,以便在我们的 C++代码中识别 Android 目标。为了对每个源文件执行此操作,我们修改了Android.mk
文件中的CFLAGS
变量。
最后,当我们编写低级代码时,检测看起来如下面的代码所示:
#if defined(_WIN32)
// windows-specific code
#elif defined(ANDROID)
// android-specific code
#endif
例如,为了使 Android 和 Windows 版本的入口点看起来相同,我们编写以下代码:
#if defined(_WIN32)
# define APP_ENTRY_POINT() int main()
#elif defined(ANDROID)
# define APP_ENTRY_POINT() int App_Init()
#endif
稍后我们将用APP_ENTRY_POINT()
宏替换int main()
的定义。
还有更多...
为了检测更多的操作系统、编译器和 CPU 架构,查看一下predef.sourceforge.net
上预定义的宏列表会很有帮助。
链接和源代码组织
在之前的食谱中,我们学习了如何创建基本的包装器,以允许我们的应用程序在 Android 和 Windows 上运行。然而,由于源代码量较少且适合放在单个文件中,我们采用了临时方法。我们必须以适合在 Windows 和 Android 上构建大型项目代码的方式组织我们的项目源文件。
准备工作
回顾一下App3
项目的文件夹结构。我们在App2
文件夹中拥有src
和jni
文件夹。jni/Android.mk
、jni/Application.mk
和build.xml
文件指定了 Android 构建过程。为了启用 Windows 可执行文件的创建,我们添加了一个名为Makefile
的文件,该文件引用了main.cpp
文件。
如何操作...
下面是Makefile
的内容:
CC = gcc
all:
$(CC) -o main.exe main.cpp -lgdi32 -lstdc++
当我们添加越来越多的与操作系统无关的逻辑时,代码位于.cpp
文件中,这些文件不引用任何特定于操作系统的头文件或库。对于前几章,这个简单的框架足够了,它将帧渲染和事件处理委托给可移植的、与操作系统无关的函数(OnDrawFrame()
、OnKeyUp()
等)。
工作原理...
后续章节中的所有示例都可以通过命令行在 Windows 上使用单个 make all
命令进行构建。Android 原生代码也可以通过单个 ndk-build
命令构建。我们将在本书的其余部分使用这个约定。
签名发布 Android 应用程序
现在,我们可以创建一个跨平台应用程序,在 PC 上进行调试,并将其部署到 Android 设备上。然而,我们还不能将其上传到 Google Play,因为它还没有(尚未)使用发布密钥正确签名。
准备就绪
Android 上签名过程的详细说明在开发者手册中有提供,地址是 developer.android.com/tools/publishing/app-signing.html
。我们将专注于从命令行进行签名,并通过批处理文件自动化整个流程。
如何操作...
首先,我们需要重新构建项目,并创建 .apk
包的发布版本。让我们用 App2
项目来完成这个操作:
>ndk-build -B
>ant release
你应该会看到来自 Ant
的很多文本输出,最后类似以下命令:
-release-nosign:
[echo] No key.store and key.alias properties found in build.properties.
[echo] Please sign App2\bin\App2-release-unsigned.apk manually
[echo] and run zipalign from the Android SDK tools.
让我们使用 JDK 中的 keytool
通过以下命令生成一个自签名的发布密钥:
>keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
填写创建密钥所需的所有字段,如下面的命令所示:
Enter keystore password:
Re-enter new password:
What is your first and last name?
[Unknown]: Sergey Kosarevsky
What is the name of your organizational unit?
[Unknown]: SD
What is the name of your organization?
[Unknown]: Linderdaum
What is the name of your City or Locality?
[Unknown]: St.Petersburg
What is the name of your State or Province?
[Unknown]: Kolpino
What is the two-letter country code for this unit?
[Unknown]: RU
Is CN=Sergey Kosarevsky, OU=SD, O=Linderdaum, L=St.Petersburg, ST=Kolpino, C=RU correct?
[no]: yes
Generating 2048 bit RSA key pair and self-signed certificate (SHA1withRSA) with a validity of 10000 days
for: CN=Sergey Kosarevsky, OU=SD, O=Linderdaum, L=St.Petersburg, ST=Kolpino, C=RU
Enter key password for <alias_name>
(RETURN if same as keystore password):
[Storing my-release-key.keystore]
现在我们准备进行实际的应用程序签名。使用 JDK 中的 jarsigner
工具通过以下代码进行操作:
>jarsigner -verbose -sigalg MD5withRSA -digestalg SHA1 -keystore my-release-key.keystore bin\App2-release-unsigned.apk alias_name
这个命令是交互式的,它将要求用户输入密钥库密码和密钥密码。然而,我们可以通过以下方式在批处理文件中提供密码:
>jarsigner -verbose -sigalg MD5withRSA -digestalg SHA1 -keystore my-release-key.keystore -storepass 123456 –keypass 123456 bin\App2-release-unsigned.apk alias_name
密码应与创建发布密钥和密钥库时输入的信息相匹配。
在我们能够安全地在 Google Play 上发布 .apk
包之前,还有一步需要完成。Android 应用程序可以使用 mmap()
调用访问 .apk
中的未压缩内容。然而,mmap()
可能会对底层数据施加一些对齐限制。我们需要将 .apk
中的所有未压缩数据按照 4 字节边界对齐。Android SDK 提供了 zipalign
工具来完成这个任务,如下面的命令所示:
>zipalign -v 4 bin\App2-release-unsigned.apk App2-release.apk
现在,我们的 .apk
已经准备好发布。
另请参阅
- 第二章,移植通用库
第二章: 移植通用库
在本章中,我们将涵盖:
-
为 Windows 编译本地静态库
-
为 Android 编译本地静态库
-
编译 libcurl 网络库
-
编译 OpenAL 库
-
编译 libvorbis、libmodplug 和 libtheora
-
使用 FreeImage 图形库
-
使用 FreeType 库进行文本渲染
-
在物理中实现定时
-
在 2D 中渲染图形
-
设置 Box2D 模拟
-
构建 ODE 物理库
引言
本章介绍如何使用Android NDK将现有的流行 C/C++库移植到 Android。这些库广泛应用于在 C++中完全实现具有图形、声音和物理模拟的富特性应用程序。仅仅编译库并没有多大意义。因此,与 FreeImage、FreeType 和 Box2D 相关的部分提供了最小示例,以演示每个库的功能。音频和网络库将在后续章节中详细讨论。我们将向您展示如何编译库,当然也会提供一些简短的示例和关于如何开始使用它们的提示。
在不同的处理器和操作系统间移植库时典型的陷阱包括内存访问(结构对齐/填充)、字节序(大小端)、调用约定和浮点问题。下面描述的所有库都很好地处理了这些问题,即使其中一些库并不正式支持 Android NDK,修复这些问题也只是几个编译器开关的问题。
要构建上述任何库,我们需要为 Windows 版本创建 makefile,并为 Android NDK 创建一对Android.mk
和Application.mk
文件。库的源文件被编译成目标文件。一系列目标文件组合成一个存档,这也称为静态库。稍后,这个静态库可以作为链接器的输入传递。我们从 Windows 版本开始,因为Android.mk
和Application.mk
文件是基于标准 makefile 构建的。
为 Windows 编译本地静态库
要构建 Windows 版本的库,我们需要一个 C++编译器。我们使用第一章中描述的 GCC 工具链的 MinGW。对于每个库,我们有一系列的源代码文件,并且我们需要得到静态库,一个带有.a
扩展名的文件。
准备就绪
假设src
目录包含我们需要为 Android 构建的库的源代码。
如何操作...
-
让我们从编写 makefile 开始:
CFLAGS = -I src
这行定义了一个变量,其中包含编译器命令行参数的列表。在我们的例子中,我们指示编译器在
src
目录中查找头文件。如果库的源代码跨越许多目录,我们需要为每个目录添加–I
开关。 -
接下来,我们为每个源文件添加以下行:
<SourceFile>.o: gcc $(CFLAGS) –c <SourceFile>.cpp –o <SourceFile>.o
<SourceFile>
应该被替换为实际的.cpp
源文件名,并且针对每个源文件都要编写这些行。 -
现在,我们添加目标文件列表:
ObjectFiles = <SourceFile1>.o <SourceFile2>.o ...
-
最后,我们编写库的目标:
<LibraryName>: ar –rvs <LibraryName>.a $(ObjectList)
注意
makefile 中的每一行,除了空行和目标名称,都应该以制表符开头。
-
要构建库,请调用以下命令:
>make <LibraryName>.a
当在程序中使用库时,我们将
LibraryName.a
文件作为参数传递给gcc
。
工作原理...
Makefiles 由类似于编程语言中的子例程的目标组成,通常每个目标都会生成一个目标文件。例如,我们已经看到,库的每个源文件都被编译成相应的目标文件。
目标名称可能包含文件名模式,以避免复制和粘贴,但在最简单的情况下,我们只需列出所有的源文件,并替换SourceFile
为适当的文件名,复制这些行。gcc
命令后的–c
开关是编译源文件的选项,–o
指定输出目标文件的名字。$(CFLAGS)
符号表示将CFLAGS
变量的值替换到命令行中。
Windows 的 GCC 工具链包括AR
工具,这是归档器的缩写。我们库的 makefiles 调用此工具来创建库的静态版本。这是在 makefile 的最后几行完成的。
还有更多...
下面是一些编写 makefiles 的技巧:
-
当带有目标文件列表的行变得过长时,可以使用反斜杠符号进行拆分,如下所示:
ObjectFileList = File1.o \ ... \ FileN.o
注意
反斜杠后面不应该有空格。这是
make
工具的一个限制。 -
有时需要注释。这可以通过编写以井号开头的行来完成:
# This line is a comment
如果库的头文件不在源文件所在的目录中,我们必须将这些目录添加到CFLAGS
列表中。
为 Android 编译本地静态库
Android NDK 包括针对每种支持处理器的多个 GCC 和 Clang 工具链。
准备就绪
从源代码构建静态库时,我们遵循与 Windows 版本类似的步骤。
如何操作...
-
创建一个名为
jni
的文件夹,并创建一个Application.mk
文件,其中包含适当的编译器开关,并相应地设置库的名称。例如,FreeImage 库的一个示例应如下所示:APP_OPTIM := release APP_PLATFORM := android-8 APP_STL := gnustl_static APP_CPPFLAGS += -frtti APP_CPPFLAGS += -fexceptions APP_CPPFLAGS += -DANDROID APP_ABI := armeabi-v7a x86 APP_MODULES := FreeImage
-
Android.mk
文件与我们之前章节为示例应用程序编写的类似,但有一些例外。在文件的顶部,必须定义一些必要的变量。让我们看看 FreeImage 库的Android.mk
文件可能如下所示:# Android API level TARGET_PLATFORM := android-8 # local directory LOCAL_PATH := $(call my-dir) # the command to reset the compiler flags to the empty state include $(CLEAR_VARS) # use the complete ARM instruction set LOCAL_ARM_MODE := arm # define the library name and the name of the .a file LOCAL_MODULE := FreeImage # add the include directories LOCAL_C_INCLUDES += src \ # add the list of source files LOCAL_SRC_FILES += <ListOfSourceFiles>
-
定义一些常见的编译器选项:将所有警告视为错误(
-Werror
),定义ANDROID
预处理符号,设置system
包含目录:COMMON_CFLAGS := -Werror -DANDROID -isystem $(SYSROOT)/usr/include/
-
编译标志根据选定的 CPU 架构而定:
ifeq ($(TARGET_ARCH),x86) LOCAL_CFLAGS := $(COMMON_CFLAGS) else LOCAL_CFLAGS := -mfpu=vfp -mfloat-abi=softfp -fno-short-enums $(COMMON_CFLAGS) endif
-
由于我们正在构建一个静态库,我们需要在 makefile 文件末尾添加以下行:
include $(BUILD_STATIC_LIBRARY)
工作原理...
Android NDK 开发者提供了一组自己的规则来构建应用程序和库。在前一章中,我们看到了如何构建带有.so
扩展名的共享对象文件。在这里,我们只需将BUILD_SHARED_LIBRARY
符号替换为BUILD_STATIC_LIBRARY
,并明确列出构建每个对象文件所需的源文件。
注意
当然,你可以构建一个共享库并以动态方式将你的应用程序链接到它。然而,这通常是在库位于系统内并被多个应用程序共享时是一个不错的选择。在我们的情况下,由于我们的应用程序是库的唯一用户,静态链接将使项目链接和调试更加容易。
编译 libcurl 网络库
libcurl 库是处理众多网络协议的本机应用程序的实际标准。在 Windows 主机上为 Android 编译 libcurl 需要进行一些额外的步骤。我们在此食谱中解释它们。
准备工作
从库主页下载 libcurl 源代码:curl.haxx.se/libcurl/
。
如何操作...
-
由于 libcurl 库的构建过程基于
Autoconf
,我们实际上在构建库之前需要生成一个curl_config.h
文件。从包含未打包的 libcurl 发行包的文件夹中运行configure
脚本。交叉编译命令行标志应设置为:--host=arm-linux CC=arm-eabi-gcc
-
CPPFLAGS
变量的-I
参数应指向你的 NDK 文件夹中的/system/core/include
子文件夹,在我们的情况下:CPPFLAGS=”-I D:/NDK/system/core/include”
-
libcurl 库可以通过多种方式进行定制。我们使用这组参数(除了 HTTP 之外禁用所有协议):
>configure CC=arm-eabi-gcc --host=arm-linux --disable-tftp --disable-sspi --disable-ipv6 --disable-ldaps --disable-ldap --disable-telnet --disable-pop3 --disable-ftp --without-ssl --disable-imap --disable-smtp --disable-pop3 --disable-rtsp --disable-ares --without-ca-bundle --disable-warnings --disable-manual --without-nss --enable-shared --without-zlib --without-random --enable-threaded-resolver
-
configure
脚本将生成一个有效的curl_config.h
头文件。你可以在配套材料中找到它。 -
进一步编译需要一套常规的
Android.mk/Application.mk
文件,这些文件也包含在配套材料中。
工作原理…
一个简单的使用示例如下所示:
CURL* Curl = curl_easy_init();
curl_easy_setopt( Curl, CURLOPT_URL, “http://www.google.com” );
curl_easy_setopt( Curl, CURLOPT_FOLLOWLOCATION, 1 );
curl_easy_setopt( Curl, CURLOPT_FAILONERROR, true );
curl_easy_setopt( Curl, CURLOPT_WRITEFUNCTION, &MemoryCallback );
curl_easy_setopt( Curl, CURLOPT_WRITEDATA, 0 );
curl_easy_perform( Curl );
curl_easy_cleanup( Curl );
在这里,MemoryCallback()
是处理接收到的数据的函数。将网络响应转储到终端的最小化不安全实现可以如下所示:
size_t MemoryCallback( void* P, size_t Size, size_t Num, void* )
{
printf( (unsigned char*)P) );
}
在 Windows 应用程序中,检索到的数据将在屏幕上打印。同样的代码在 Android 中将像一个哑巴一样工作,不会产生任何可见的副作用。
还有更多…
为了处理 SSL 加密连接,我们需要告诉 libcurl 我们的系统证书位于何处。这可以在curl_config.h
文件开头通过定义CURL_CA_BUNDLE
来完成:
#define CURL_CA_BUNDLE “/etc/ssl/certs/ca-certificates.crt”
另请参阅
- 第三章,网络通信
编译 OpenAL 库
OpenAL 是一个跨平台的音频库,被许多游戏引擎使用。以下是如何为 Android 构建它的注意事项。
准备工作
从 Martins Mozeiko 的页面下载他移植的源代码:pielot.org/2010/12/14/openal-on-android/
。
库的主页如下:github.com/AerialX/openal-soft-android
。
如何操作...
-
为了渲染生成的或保存的音频流,我们使用 OpenAL 库,它是使用随附材料中包含的标准
Android.mk
和Application.mk
配置文件编译的。 -
该库的 Android 端口实际上是由 Martins Mozeiko 为 Android Java 类
android.media.AudioTrack
使用 JNI 制作的一个包装器。代码是在 GNU Library General Public License 下授权的,并包含在本书的补充材料中。
工作原理…
初始化和反初始化 OpenAL 的最小源代码如下所示:
ALCdevice* Device = alcOpenDevice( NULL );
ALCcontext* Context = alcCreateContext( Device, NULL );
alcMakeContextCurrent( Context );
…
alcDestroyContext( Context );
alcCloseDevice( Device );
另请参阅
- 第五章,跨平台音频流传输
编译 libvorbis、libmodplug 和 libtheora
对于音频流的加载,我们使用libogg、libvorbis和libmodplug。视频流的处理方式类似,使用libtheora库。在这里,我们仅提供如何从源代码构建库的一般性提示,因为一旦你有了我们的典型Android.mk
和Application.mk
文件,实际的构建过程是非常简单的。
准备工作
从www.xiph.org/downloads
下载 libvorbis 和 libtheora 编解码器的源代码,以及从modplug-xmms.sourceforge.net
下载 libmodplug 库。
如何操作...
-
libvorbis 和 libtheora 都依赖于 libogg。使用提供的 makefiles 和包含源文件列表的标准
Android.mk
文件,这些库的编译是非常简单的。注意
libvorbis 和 libtheora 库的 Makefiles 必须引用 libogg 的包含目录。
-
libmodplug 是 Olivier Lapicque 开发的开源跟踪音乐解码器。我们提供了他库的简化版本,包含最流行的跟踪文件格式的加载器。它仅由三个文件组成,并且对 Android 和 Linux 的支持非常出色。该库在大端 CPU 上没有任何问题。
使用 FreeImage 图形库
FreeImage 是一个可移植的图形库,它统一了诸如 JPEG、TIFF、PNG、TGA、高动态范围 EXR 图像等流行图像格式的加载和保存。
准备工作
从库的主页freeimage.sourceforge.net
下载最新的 FreeImage 源代码。我们使用的是 2012 年 10 月发布的 Version 3.15.4。
如何操作...
-
Android.mk
和Application.mk
文件都是相当标准的。前者应该包含以下GLOBAL_CFLAGS
的定义:GLOBAL_CFLAGS := -O3 -DHAVE_CONFIG_H=1 -DFREEIMAGE_LIB-isystem $(SYSROOT)/usr/include/
-
不幸的是,Android NDK 运行时库中缺少了 FreeImage 内部使用的
lfind()
函数(该函数在 LibTIFF4 库中使用,而 FreeImage 又使用了该库)。以下是它的实现方法:void* lfind( const void * key, const void * base, size_t num, size_t width, int (*fncomparison)(const void *, const void * ) ) { char* Ptr = (char*)base; for ( size_t i = 0; i != num; i++, Ptr+=width ) { if ( fncomparison( key, Ptr ) == 0 ) return Ptr; } return NULL; }
-
现在,一个命令就能完成这项工作:
>ndk-build
工作原理...
图像是作为原始像素数据集合的 2D 数组表示,但存储这个数组的方法有很多:可能会应用一些压缩,可能会涉及一些非 RGB 色彩空间,或者非平凡的像素布局。为了避免处理所有这些复杂性,我们建议使用 Herve Drolon 的 FreeImage 库。
我们需要能够将图像文件数据作为内存块处理,而 FreeImage 支持这种输入方式。假设我们有一个名为1.jpg
的文件,我们使用fread()
或ifstream::read()
调用将其读取到数组char Buffer[]
中。数组的大小存储在Size
变量中。然后,我们可以创建FIBITMAP
结构,并使用FreeImage_OpenMemory()
API 调用将缓冲区加载到这个FIBITMAP
结构中。FIBITMAP
结构几乎是我们想要的 2D 数组,包含了像素布局和图像大小的额外信息。要将它转换为 2D 数组,FreeImage 提供了函数FreeImage_GetRowPtr()
,它返回指向第i行原始 RGB 数据的指针。反之,我们的帧缓冲区或任何其他 2D RGB 图像也可以使用FreeImage_SaveMemory()
编码到内存块中,并通过单个fwrite()
或ofstream::write()
调用保存到文件。
下面是加载 FreeImage 支持的任何图片格式(例如 JPEG、TIFF 或 PNG)并将其转换为 24 位 RGB 图像的代码。其他支持的像素格式,如 RGBA 或浮点数 EXR,将被自动转换为 24 位颜色格式。为了简洁起见,此代码中我们不处理错误。
让我们声明一个结构体,用于保存图像尺寸和像素数据:
struct sBitmap
{
int Width;
int Height;
void* RGBPixels;
};
从内存块到sBitmap
结构体解码图像的方式如下:
void FreeImage_LoadImageFromMemory( unsigned char* Data, unsigned
int Size, sBitmap* OutBitmap )
{
FIMEMORY* Mem = FreeImage_OpenMemory( Data, Size );
FREE_IMAGE_FORMAT FIF=FreeImage_GetFileTypeFromMemory(Mem, 0);
FIBITMAP* Bitmap = FreeImage_LoadFromMemory( FIF, Mem, 0 );
FIBITMAP* ConvBitmap;
FreeImage_CloseMemory( Mem );
ConvBitmap = FreeImage_ConvertTo24Bits( Bitmap );
FreeImage_Unload( Bitmap );
Bitmap = ConvBitmap;
OutBitmap->Width = FreeImage_GetWidth( Bitmap );
OutBitmap->Height = FreeImage_GetHeight( Bitmap );
OutBitmap->RGBPixels = malloc( OutBitmap->Width * OutBitmap->Height * 3 );
FreeImage_ConvertToRawBits( OutBitmap->RGBPixels, Bitmap, OutBitmap->Width * 3, 24, 0, 1, 2, false );
FreeImage_Unload( Bitmap );
}
保存图像甚至更简单。保存表示图像的数组img
,其宽度为W
,高度为H
,包含每像素BitsPP
位:
void FreeImage_Save( const char* fname, unsigned char* img, int W, int H, int BitsPP )
{
// Create the FIBITMAP structure
// using the source image data
FIBITMAP* Bitmap = FI_ConvertFromRawBits(img,
W, H, W * BitsPP / 8,
BitsPP, 0, 1, 2, false);
// save PNG file using the default parameters
FI_Save( FIF_PNG, Bitmap, fname, PNG_DEFAULT );
FI_Unload( Bitmap );
}
将FIF_PNG
更改为FIF_BMP
、FIF_TIFF
或FIF_JPEG
中的任何一个,将输出文件格式分别更改为 BMP、TIFF 或 JPEG。
还有更多...
要理解从内存块中读取图像的重要性,我们应牢记两点。诸如Picasa和Flickr之类的网络服务提供了图像的 URL,然后使用第三章网络通信中的技术将这些图像下载到内存中。为了避免浪费时间,我们不将这个内存块保存到磁盘,而是直接使用 FreeImage 库从内存中解码。从压缩档案中读取图像文件也同样适用。
另请参阅
- 第四章,组织虚拟文件系统
使用 FreeType 库进行文本渲染
FreeType 已成为高质量文本渲染的实际标准。该库本身非常易于使用,静态版本的编译依赖于与其他本章库类似的 makefile。
准备开始
从库的主页下载最新的源代码:www.freetype.org
。
FreeType 的主要概念包括:字体面、字形和位图。字体面是针对给定编码的字体中所有字符的集合。这正是存储在 .ttf
文件中的内容(除了版权信息和其他类似的元信息)。每个字符称为字形,使用几何基本元素表示,如样条曲线。这些字形不是我们可以逐像素复制到屏幕或帧缓冲区的东西。我们需要使用 FreeType 光栅化函数来生成字形的位图。
让我们来看一个单独的字形:
FreeType 字形度量
xMin
、xMax
、yMin
和 yMax
值定义了字形在逻辑坐标中的尺寸,而前进值显示了如果没有字距调整,下一个字形开始的位置。一旦我们想要在屏幕上渲染,我们需要将 FreeType 使用的逻辑坐标转换为屏幕坐标。FreeType 避免使用浮点计算,并将所有内容存储在 26.6 定点格式中(www.freetype.org/freetype2/docs/glyphs/glyphs-6.html
)。为了转换从 FreeType 获取的这些复杂值,我们将这些值向右移动六位(相当于整数除以 64),得到我们可以轻松使用的值。
单独渲染每个字符的图像是不够的。有时字符在相互靠近渲染时看起来更好,某些字母组合甚至可能产生新的字形。屏幕上字符间距离的变化称为字距调整,FreeType 提供了计算字形之间偏移量的函数。将几个字形组合成一个字形称为连字,这超出了本书的范围(详情和参考资料请见en.wikipedia.org/wiki/Typographic_ligature
)。在第七章,跨平台 UI 和输入系统中,我们仅使用简单的字距调整,这对于我们的交互式应用程序来说已经足够好了。
为了展示 FreeType 的基本使用方法,我们将在本食谱中编写代码实现:
-
使用等宽字体的 ASCII 字符串渲染器。
-
用于等宽字体的基于 FreeType 的纹理生成器。
后面,我们将回到涉及比例字体、UTF-8 编码和字距调整的高级 FreeType 使用方法。
如何操作...
-
对于等宽字体和 8 位 ASCII 字符集,我们可以使用一个包含所有 256 个字符的单一预渲染位图来简化渲染代码。为了制作这个位图,我们编写了一个小工具,它读取 TrueType 字体,并输出一个 512 x 512 像素的方形位图,其中包含 16 × 16 字符网格:
#include <stdio.h> #include <string.h>
-
包含 FreeType 头文件:
#include <ft2build.h> #include FT_FREETYPE_H
-
声明每侧的字符数以及每个字符的大小:
#define CHAR_SIZE 16 #define SLOT_SIZE 32
-
声明一个数组以 RGBA 格式存储输出位图:
#define WIDTH CHAR_SIZE*SLOT_SIZE #define HEIGHT CHAR_SIZE*SLOT_SIZE unsigned char image[HEIGHT][WIDTH][4];
-
使用 FreeImage 库声明一个外部定义的例程来保存
.bmp
文件:void write_bmp(const char *fname, int w, int h, int bits_pp, unsigned char *img);
-
声明在位置
(x, y)
处渲染FT_Bitmap
的渲染器如下:void draw_bitmap( FT_Bitmap* bitmap, FT_Int x, FT_Int y) { FT_Int i, j, p, q; FT_Int x_max = x + bitmap->width, y_max = y + bitmap->rows;
-
遍历源位图的像素:
for ( i = x, p = 0; i < x_max; i++, p++ ) for ( j = y, q = 0; j < y_max; j++, q++ ) { if (i < 0 || j < 0 || i >= WIDTH || j >= HEIGHT ) continue;
-
从位图中读取值
v
并将四个 RGBA 组件的每一个复制到输出中:unsigned char v = bitmap->buffer[q * bitmap->width + p]; for(int k = 0 ; k < 4 ; k++) image[j][i][k] = v; } }
-
应用程序的主函数
main()
如下所示:int main() {
-
将位图清除为黑色:
memset( &image[0][0][0], 0, sizeof(image) );
-
初始化 FreeType 库:
FT_Library library; FT_Init_FreeType( &library );
-
创建面(face)对象:
FT_Face face; FT_New_Face( library, “font.ttf”, 0, &face );
-
设置字符大小。我们声明了
CHAR_SIZE
来表示位图中单个字符的像素数。乘数64
是使用的,因为 FreeType 的单位等于 1/64 点。值100
对应于每英寸 100 个点的水平分辨率:FT_Set_Char_Size( face, CHAR_SIZE * 64, 0, 100, 0 ); FT_GlyphSlot slot = face->glyph;
-
渲染 ASCII 表中的每个字符:
for ( int n = 0; n < 256; n++ ) {
-
加载下一个字形图像到槽中,覆盖之前的图像,并忽略错误:
if( FT_Load_Char( face, n, FT_LOAD_RENDER ) ) { continue; }
-
计算字形在结果位图中的非变换原点:
FT_Vector pen; pen.x = (n % 16) * SLOT_SIZE * 64; pen.y = ( HEIGHT - (n / 16) * SLOT_SIZE) * 64;
-
现在,转换位置,绘制到我们的目标位图:
draw_bitmap( &slot->bitmap, (pen.x/64)+slot->bitmap_left, EIGHT-(pen.y / 64) - slot->bitmap_top ); }
-
将生成的字体纹理保存为矩形
.bmp
图像文件:write_bmp( “font.bmp”, WIDTH, HEIGHT, 32, (unsigned char*)image );
-
清除字体面并释放库分配的资源:
FT_Done_Face(face); FT_Done_FreeType(library); return 0; }
-
现在,我们有一个以左至右书写的 ASCII 字符串,我们想要构建这个字符串的图形表示。我们遍历字符串中的字符来逐个渲染它们。在每次迭代结束时,我们将当前字符的位图复制到帧缓冲区,然后使用固定的字体宽度(即
SLOT_SIZE
值)增加当前位置。 -
这是使用预渲染位图字体来呈现文本字符串的完整代码。我们使用字体数组来存储我们字体的 RGB 位图:
unsigned char* font;
-
输出帧缓冲区的宽度和高度定义如下:
int w = 1000; int h = 1000; int fw, fh; int char_w, char_h;
-
将单个字符渲染到位图缓冲区:
void render_char(unsigned char* buf, char ch, int x, int y, int col) { int u = (ch % 16) * char_w; int v = char_h / 2 + ((((int)ch) >> 4) - 1) * char_h;
-
遍历当前字符的像素:
for (int y1 = v ; y1 < v + char_h ; y1++ ) for (int x1 = u ; x1 <= u + char_w ; x1++ ) { int m_col = get_pixel(font, fw, fh, x1, y1);
-
只绘制非零像素。这将保留帧缓冲区中的现有内容:
if(m_col != 0) put_pixel(buf, w, h, x+x1-u, y+y1-v, col); } }
-
将完整的 ASCII 文本行渲染到缓冲区:
void render_text(unsigned char* buf, const char* str, int x, int y, int col) { const char* c = str; while (*c) { render_char(buf, *c, x, y, col); c++;
-
以固定数量的像素前进:
x += char_w; } }
工作原理…
让我们读取 FreeType 字体生成器的输出。我们使用以下代码来测试它:
font = read_bmp( “font.bmp”, &fw, &fh );
char_w = fw / CHAR_SIZE;
char_h = fh / CHAR_SIZE;
分配并清除输出 3 通道 RGB 位图:
unsigned char* bmp = (unsigned char* )malloc( w * h * 3 );
memset( bmp, 0, w * h * 3 );
在位置(10,10)
处渲染白色文本行:
render_text( bmp, “Test string”, 10, 10, 0xFFFFFF );
将结果位图保存到文件:
write_bmp( “test.bmp”, w, h, bmp );
free( bmp );
还有更多...
我们鼓励读者访问www.1001freefonts.com
寻找一些免费字体,使用所描述的 FreeType 字体生成器为这些字体创建.bmp
文件,并使用预渲染的字符来渲染字符串。
在物理中实现计时
本章的其余部分专门介绍两个物理模拟库:Box2D(2D 模拟)和 Open Dynamics Engine(3D 模拟)。构建这些并不困难,因此我们将重点放在如何实际使用它们。Box2D 和 ODE 的 API 仅提供计算模拟中刚体当前位置的函数。首先,我们必须调用计算例程。然后,我们必须将身体的物理坐标转换成与屏幕相关的坐标系。将物理模拟与渲染和计时连接起来是本节处理的主要问题。
准备就绪
几乎每个刚体物理库都提供了世界、物体(或身体)、约束(或关节)以及形状的抽象。这里的世界只是一个包含身体和附着在身体上的关节的集合。形状定义了身体如何碰撞。
要基于物理模拟创建动态应用程序,我们必须能够在任何时刻渲染物理场景。同时,我们还需要将离散的计时器事件转换成看似连续的物体位置计算过程。
在这里,我们解释了计时和渲染,然后提供了一个使用 Box2D 库的完整示例,即App4
。
如何操作...
-
为了在屏幕上动画化所有内容,我们需要设置一个计时器。在 Android 中,我们尽可能快地进行时间步进,并且在渲染循环的每次迭代中,我们只需调用
GetSeconds()
函数并计算前一个时间与当前时间之间的差值。Wrappers_Android.h
文件中的GetSeconds()
函数代码使用了标准的POSIXgettimeofday()
函数:double GetSeconds() {
-
将时间从微秒转换为秒的系数:
const unsigned usec_per_sec = 1000000;
-
获取当前时间:
struct timeval Time; gettimeofday( &Time, NULL );
-
计算微秒数:
int64_t T1 = Time.tv_usec + Time.tv_sec * usec_per_sec;
-
返回当前时间(秒)。这里需要
double
精度,因为计时器从系统启动时刻开始计时,32 位的float
精度不够:return (double)( T1 ) / (double)usec_per_sec; }
-
我们使用三个变量来记录当前时间、之前的时间和总时间。首先,我们初始化
g_OldTime
和g_NewTime
时间计数器:g_OldTime = GetSeconds(); g_NewTime = g_OldTime;
-
在开始之前,总时间计数器应设为零:
g_ExecutionTime = 0;
-
每帧我们调用
GenerateTicks()
方法来设置动画:void GenerateTicks() { g_NewTime = GetSeconds();
-
计算自上次更新以来经过的时间:
float DeltaSeconds = static_cast<float>(g_NewTime-g_OldTime); g_OldTime = g_NewTime;
-
使用非零秒数调用
OnTimer()
例程:if (DeltaSeconds > 0) { OnTimer(DeltaSeconds); } }
-
对于 Windows 版本,使用
SetTimer()
函数进行时间步进,该函数每隔 10 毫秒启用一个系统计时器事件:SetTimer( hWnd, 1, 10, NULL);
-
每次这些毫秒经过,
WM_TIMER
事件会被发送到我们的窗口函数。我们在switch
构造中添加另一个case
,只需调用OnTimer()
方法:LRESULT CALLBACK MyFunc( HWND h, UINT msg, WPARAM w, LPARAM p ) ... case WM_TIMER:
-
由于我们即将改变状态,重新绘制一切:
InvalidateRect(h, NULL, 1);
-
使用 0.01 秒的时间片重新计算一切:
OnTimer(0.01); break;
如第二章,移植通用库所述,新的OnTimer()
回调函数与 Windows 或 Android 的特定内容无关。
工作原理...
现在,当我们有了为我们生成的时间器事件时,我们可以继续计算刚体的位置。这是一个解决运动方程的复杂过程。简单来说,给定当前的位置和方向,我们想要计算场景中所有刚体的新位置和方向:
positions_new = SomeFunction(positions_old, time_step);
在这个伪代码中,positions_new
和positions_old
是与刚体位置和方向的新旧数组,而time_step
是我们应该推进时间计数器的秒数值。通常,我们需要使用0.05
秒或更低的时间步长更新一切,以确保我们以足够高的精度计算位置和方向。对于每个逻辑计时器事件,我们可能需要进行一个或多个计算步骤。为此,我们引入了TimeCounter
变量,并实现了所谓的时间分片:
const float TIME_STEP = 1.0f / 60.0f;
float TimeCounter = 0;
void OnTimer (float Delta)
{
g_ExecutionTime += Delta;
while (g_ExecutionTime > TIME_STEP)
{
调用 Box2D 的Step()
方法,重新计算刚体的位置,并将时间计数器减一:
g_World->Step(Delta);
g_ExecutionTime -= TIME_STEP;
}
}
所提供的代码保证了对于时间值t
,Step()
方法会被调用t / TIME_STEP
次,且物理时间和逻辑时间之间的差值不会超过TIME_STEP
秒。
另请参阅…
- 第八章,编写一个匹配 3 游戏
在 2D 环境中渲染图形
为了渲染一个 2D 场景,我们使用线框模式。这只需要实现Line2D()
过程,其原型如下:
Line2D(int x1, int y1, int x2, int y2, int color);
准备开始
这可以是对 Bresenham 算法的简单实现(en.wikipedia.org/wiki/Bresenham’s_line_algorithm
),本书中没有提供代码以节省空间。有关App4
的Rendering.h
和Rendering.cpp
文件,请参见随书附带的材料。该书的补充材料可以从www.packtpub.com/support下载。
如何操作…
-
为了将模拟物理世界中的对象转换到 Box2D 库的 2D 环境中,我们必须设置一个坐标变换:
[x, y] [X_screen, Y_screen]
-
为此,我们引入了几个系数,
XScale
,YScale
,XOfs
,YOfs
,以及两个公式:X_screen = x * XScale + XOfs Y_screen = y * YScale + YOfs
-
它们的工作原理如下:
int XToScreen(float x) { return Width / 2 + x * XScale + XOfs; } int YToScreen(float y) { return Height / 2 - y * YScale + YOfs; } float ScreenToX(int x) { return ((float)(x - Width / 2) - XOfs) / XScale; } float ScreenToY(int y) { return -((float)(y - Height / 2) - YOfs) / YScale; }
-
我们还引入了
Line2D()
例程的快捷方式,使用 Box2D 库的Vec2
类型直接处理向量值参数:void LineW(float x1, float y1, float x2, float y2, int col) { Line( XToScreen(x1),YToScreen(y1), XToScreen(x2),YToScreen(y2),col ); } void Line2DLogical(const Vec2& p1, const Vec2& p2) { LineW(p1.x, p1.y, p2.x, p2.y); }
工作原理…
为了渲染一个单独的盒子,我们只需要绘制连接角点的四条线。如果一个刚体的角度是Alpha
,质心坐标是x
和y
,且尺寸由宽度w
和高度h
指定,那么角点的坐标计算如下:
Vec2 pt[4];
pt[0] = x + w * cos(Alpha) + h * sin(Alpha)
pt[1] = x - w * cos(Alpha) + h * sin(Alpha)
pt[2] = x - w * cos(Alpha) - h * sin(Alpha)
pt[3] = x + w * cos(Alpha) - h * sin(Alpha)
最后,将盒子渲染为四条线:
for(int i = 0 ; i < 4 ; i++)
{
Line2DLogical(pt[i], pt[(i+1)%4]);
}
另请参阅…
- 第六章,统一 OpenGL ES 3 和 OpenGL 3
设置 Box2D 模拟
Box2D 是一个纯 C++库,不依赖于 CPU 架构,因此使用与前面章节中类似的简单makefile
和Android.mk
脚本就足以构建该库。我们使用前面章节中描述的技术来设置一个模拟。我们还有上一章中的帧缓冲区,仅使用 2D 线条渲染盒子。
准备就绪
作为奖励,库的作者 Erin Catto 提供了一个 Box2D 的简化版本。一旦你满足于仅使用现有的盒子,你可以限制自己使用BoxLite版本。
从库的主页下载最新的源代码:box2d.org
。
如何操作...
-
为了开始使用 Box2D,我们采用了本书材料中包含的经过略微修改的 BoxLite 版本的标准示例。首先,我们声明全局的
World
对象:World* g_World = NULL;
-
在
OnStartup()
例程的最后初始化它:g_World = new World(Vec2(0,0), 10); Setup(g_World);
-
OnTimer()
回调(之前食谱中使用的)通过调用Step()
方法使用TIME_STEP
常量更新g_World
对象。 -
OnDrawFrame()
回调将每个刚体的参数传递给DrawBody()
函数,该函数渲染刚体的边界框:void OnDrawFrame() { Clear(0xFFFFFF); for (auto b = g_World->bodies.begin(); b !=g_World->bodies.end(); b++ ) { DrawBody(*b); }
-
渲染每个关节:
for ( auto j = g_World->joints.begin() ; j != g_World->joints.end() ; j++ ) { DrawJoint(*j); }
-
尽可能快地更新状态:
GenerateTicks(); }
对GenerateTicks()
函数的调用为 Android 版本实际更新定时。它是使用本章中“在物理中实现定时”食谱中的想法来实现的。
它是如何工作的...
Setup()
函数是对 Box2D 原始示例代码的修改,用于设置一个物理场景。修改包括定义一些快捷方式以简化场景组装。
函数CreateBody()
和CreateBodyPos()
根据指定的位置、方向、尺寸和质量创建刚体。函数AddGround()
向g_World
添加一个静态不可移动的物体,而函数CreateJoint()
则创建一个将一个刚体附着到另一个刚体的新物理连接。
在这个示例场景中,还有一些关节连接着这些刚体。
应用程序App4
在 Android 和 Windows 上产生相同的结果,如下面的图像所示,这是其中一个模拟步骤:
还有更多...
作为练习,我们建议你尝试调整设置,并在App4
示例中添加更多自己的 2D 场景。
另请参阅
- 在物理中实现定时
构建 ODE 物理库
本食谱致力于构建开源ODE(开放动力学引擎)物理模拟库,这是互动应用中最古老的刚体模拟器之一。
准备就绪
从库的主页下载最新的源代码:www.ode.org/download.html
。
如何操作...
-
编译 ODE 与其他库没有区别。一个微妙的点是选择
single
和double
浮点精度。标准编译涉及autoconf
和automake
工具,但这里我们只需像往常一样准备Android.mk
、makefile
和odeconfig.h
。我们需要在那里定义dDOUBLE
或dSINGLE
符号,以启用single
或double
精度计算。在odeconfig.h
文件的开头有这一行:#define dSINGLE
-
它启用了单精度、32 位浮点计算,这对于简单的交互式应用程序来说已经足够了。将值更改为
dDOUBLE
可以启用双精度、64 位浮点计算:#define dDOUBLE
-
ODE 是相当复杂的软件,它包含了Ice碰撞检测库,不幸的是,在使用 Clang 编译器的最严格设置时,它会有编译问题。但是,通过注释掉
OPCODE/Ice/IceUtils.h
文件中的_prefetch
函数内容,可以轻松修复。
工作原理...
由于 ODE 在 3D 空间中计算刚体的位置和方向,因此我们必须在我们在本章中完成的简单 2D 渲染之上建立一个小型的 3D 渲染管道。为了演示 ODE 库,我们不可避免地需要一些 3D 数学知识。场景(世界)中的所有对象都有其坐标和方向,由 3D 向量和四元数组成的一对值指定。我们将它们转换为 4x4 仿射变换矩阵。然后,我们遵循坐标变换链:我们将对象空间转换为世界空间,世界空间转换为相机空间,然后通过乘以投影矩阵将相机空间转换为透视后空间。
最后,第一个透视坐标x
和y
被转换成标准化设备坐标,以适应我们的 2D 帧缓冲区,就像在 Box2D 示例中一样。摄像机固定在一个静止点上,其观察方向在我们的简单应用程序中无法更改。投影矩阵也是固定的,但没有其他限制。
还有更多...
3D 物理模拟是一个非常复杂的话题,需要阅读许多书籍。我们鼓励读者查看 ODE 社区维基页面,在ode-wiki.org/wiki
可以找到官方文档和开源示例。通过 Packt Publishing 出版的《使用 Bullet Physics 和 OpenGL 学习游戏物理》一书,可以开始游戏物理的好学习:www.packtpub.com/learning-game-physics-with-bullet-physics-and-opengl/book
。
另请参阅
- 设置 Box2D 模拟
第三章:网络
在本章中,我们将涵盖以下内容:
-
从 Flickr 和 Picasa 获取照片列表(注意:此行为重复,在翻译中应避免重复输出)
-
从 Flickr 和 Picasa 下载图片
-
执行跨平台多线程操作
-
同步跨平台原生线程
-
使用引用计数管理内存
-
实现异步任务队列
-
处理异步回调调用
-
异步处理网络工作
-
检测网络地址
-
编写 HTTP 服务器
引言
在时间上,网络本质上是一个异步且不可预测的领域。我们无法确信连接的可靠性。即使使用 TCP 协议,也不能保证数据的送达时间,且应用程序在等待套接字中的数据时完全有可能冻结。为了开发响应迅速且安全的应用程序,我们必须解决许多问题:我们需要完全控制下载过程,限制下载数据的大小,并优雅地处理出现的错误。在不深入 HTTP 协议实现细节的情况下,我们使用 libcurl 库,专注于与游戏开发相关的高级任务。
首先,我们查看 Picasa 和 Flickr 的 REST API,以下载图像列表并构建到照片的直接 URL。然后,我们探讨线程安全的异步编程,最后使用纯 Berkeley 套接字接口实现一个简单的 HTTP 服务器,用于调试目的。
本章节关于多线程编程的示例仅限于 Windows 平台,但到了章节末尾,我们将整合所学内容,创建内置 Web 服务器的 Android App5
示例。
从 Flickr 和 Picasa 获取照片列表
在上一章节,我们构建了 libcurl 库。为了回顾如何下载网页,请参考本章配套材料中的 1_CurlDownloader
示例。
关于在 C++中使用 Picasa 和 Flickr 的信息相对有限,但调用这些网站的 REST (表现层状态转移)API 与下载网页没有区别。我们需要做的是为图像列表构建正确的 URL,从此 URL 下载 XML 文件,然后解析此文件以构建单个图像 URL 列表。通常,REST API 需要某种形式的 oAuth 认证,但对于只读访问,仅使用通过简单在线注册即可获得的应用程序密钥就足够了。
注意
本食谱中的示例代码仅用于构建 URL,读者需要自行下载实际图像列表。同时,这里没有提供应用程序密钥,我们鼓励读者获取密钥并测试代码。
准备工作
每个应用程序都必须使用通过简单注册过程获得的唯一密钥对其请求进行签名。应用程序密钥和秘密密钥是类似14fc6b12345678901234567890d69c8d
的长十六进制数字。创建您的 Yahoo ID 账户并在以下网站获取应用程序密钥:www.flickr.com/services/api/misc.api_keys.html
。如果您已经有了 Yahoo ID 账户,直接前往www.flickr.com/services/apps/create
。
Picasa 照片托管服务提供了对 RSS 订阅的免费访问,并不要求客户端应用程序使用任何认证密钥。
如何操作…
-
我们希望跟上最新的照片趋势,因此我们想要获取点赞数最多的图片列表,或者最近添加的图片列表。为了访问这些列表,Flickr 提供了
flickr.interestingness.getList
和flickr.photos.getRecent
方法,而 Picasa 提供了两个 RSS 订阅:featured
和all
。以下是 Flickr RSS 订阅中最近照片的示例截图: -
为了形成 Flickr 和 Picasa 所需的 URL,我们实现了两个函数。一个是针对 Flickr 的:
std::string Flickr_GetListURL( const std::string& BaseURL, int MaxResults, int PageIndex, const std::string& SearchQuery ) { std::string Result = BaseURL + std::string( "&api_key=" ); Result += AppKey; if ( !SearchQuery.empty() ) { Result += std::string( "&q=\"" ) + SearchQuery + std::string( "\"" ); } Result += std::string( "&per_page=" ); Result += IntToStr( MaxResults );
-
列表可能很大,包含很多页面。我们可以通过索引选择一个页面:
if ( PageIndex > -1 ) { Result += std::string( "&page=" ) + IntToStr( PageIndex + 1 ); } return Result; }
-
另一个函数是针对 Picasa 的:
std::string Picasa_GetListURL( const std::string& BaseURL, int MaxResults, int PageIndex, const std::string& SearchQuery ) { std::string Result = BaseURL; Result += std::string( "kind=photo&imgmax=1600" ); if ( !SearchQuery.empty() ) { Result += std::string( "&q=\"" ) + SearchQuery + std::string( "\"" ); } Result += std::string( "&max-results=" ); Result += IntToStr( MaxResults ); if ( PageIndex > 0 ) { Result += std::string( "&start-index=" ) + IntToStr( ( int )( 1 + PageIndex * MaxResults ) ); } return Result; }
-
根据我们想要的列表,我们将
FlickrFavoritesURL
或FlickrRecentURL
常量作为Flickr_GetListURL()
函数的BaseURL
参数传递,将PicasaFavoritesURL
或PicasaRecentURL
常量作为Picasa_GetListURL()
函数的BaseURL
参数传递。 -
这里是需要字符串常量的完整列表:
const std::string AppKey = "YourAppKeyHere"; const std::string FlickrAPIBase = "http://api.flickr.com/services/rest/?method="; const std::string FlickrFavoritesURL = FlickrAPIBase + "flickr.interestingness.getList"; const std::string FlickrRecentURL = FlickrAPIBase + "flickr.photos.getRecent"; const std::string PicasaAPIBase = "http://picasaweb.google.com/data/feed/api/"; const std::string PicasaFavoritesURL = PicasaAPIBase + "featured/?"; const std::string PicasaRecentURL = PicasaAPIBase + "all/?";
-
MaxResults
参数限制了列表中的图片数量。PageIndex
参数指定跳过多少个结果页面,而SearchQuery
字符串可以用来获取描述中包含给定文本的图片。 -
Flickr 版本使用了应包含获取的应用程序密钥的全局字符串常量
AppKey
。
它是如何工作的…
我们形成了 URL;在这种情况下,它是 Flickr 用户点赞图片的第一页:
string URL = Flickr_GetListURL(FlickrFavoritesURL, 15, 0, "");
然后,我们可以将这个 URL 传递给我们的 HTTP 下载器,并接收到包含图片列表的 XML 文件。对 Picasa 也可以这样做;注意基于 1 的页面索引:
string URL = Picasa_GetListURL(PicasaFavoritesURL, 15, 1, "");
这些函数的完整源代码可以在2_FlickrAndPicasa
文件夹中的PhotoAPI.cpp
文件中找到。
还有更多…
提供的示例不包含 Flickr 的有效应用程序密钥。另外,根据 Flickr 的许可协议,您的应用程序在一个屏幕上可能不会显示超过十五张图片。
在www.flickr.com/services/api/
上有关于 Flickr API 的广泛文档。
另请参阅
- 从 Flickr 和 Picasa 下载图片
从 Flickr 和 Picasa 下载图片
我们有一个以 XML 格式下载的图片列表,我们在 Flickr 和 Picasa 获取照片列表的食谱中下载了它。现在让我们从照片托管服务中下载实际的照片。
准备就绪
这里,我们需要从 Flickr 或 Picasa 获取图片列表以开始操作。使用上一个食谱下载该列表。
如何操作…
-
获取列表后,我们从列表中提取单个图像 ID。拥有这些 ID 允许我们形成单个图像的 URL。Flickr 使用复杂的图像 URL 形成过程,而 Picasa 直接存储 URL。这两种服务都可以生成 XML 和 JSON 格式的响应。我们将向您展示如何使用我们的小型临时解析器解析 XML 响应。但是,如果你的项目中已经使用某种 XML 或 JSON 解析库,我们也鼓励你用它来完成这项任务。
-
要解析 Flickr XML 列表,我们使用以下函数:
void Flickr_ParseXMLResponse( const std::string& Response, std::vector<std::string>& URLs ) { using std::string::npos; size_t begin = Response.find( "<photos" ); if ( begin == npos ) { return; } begin = Response.find_first_of( '>', begin ); if ( begin == npos ) { return; } size_t end = Response.find( "/photos>" ); if ( end == npos ) { return; } size_t cur = begin; size_t ResLen = Response.length();
-
用临时方法解析字符串。你可以使用你喜欢的 XML 库代替这个循环:
while ( cur < ResLen ) { using std::string::npos; size_t s_begin = Response.find( "<photo", cur ); if ( s_begin == npos ) { break; } size_t s_end = Response.find( "/>", s_begin ); if ( s_end == npos ) { break; } std::string Part = Response.substr( s_begin,s_end - s_begin + 2 ); URLs.push_back( Part ); cur = s_end + 2; } }
-
Picasa RSS 订阅功能的 XML 格式如下所示:
void Picasa_ParseXMLResponse( const std::string& Response, std::vector<std::string>& URLs ) { using std::string::npos; size_t cur = 0; size_t ResLen = Response.length();
-
我们使用类似的临时代码解析提供的字符串:
while ( cur < ResLen ) { size_t s_begin = Response.find( "<media:content ",cur ); if ( s_begin == npos ) { break; } size_t s_end = Response.find( "/>", s_begin ); if ( s_end == npos ) { break; } std::string new_s = Response.substr( s_begin,s_end - s_begin + 2 ); URLs.push_back( ExtractURLAttribute( new_s,"url=\'", '\'' ) ); cur = s_end + 2; } }
-
辅助函数
ExtractURLAttribute()
用于从 XML 标签中提取单个属性的值:std::string ExtractURLAttribute( const std::string& InStr, const std::string& AttrName, char Delim ) { size_t AttrLen = AttrName.length(); size_t pos = InStr.find( AttrName );
-
扫描字符串直到末尾:
if ( pos != std::string::npos ) { for ( size_t j = pos+AttrLen ; j < InStr.size() ; j++ ) { if ( InStr[j] == Delim ) { break; } } return InStr.substr( pos + AttrLen, j - pos - AttrLen ); } return ""; }
-
最后,为了形成选定分辨率的 Flickr 图片 URL,我们使用这个函数:
std::string Flickr_GetDirectImageURL( const std::string& InURL, int ImgSizeType ) {
-
首先,我们需要使用来自
InURL
的地址准备参数:string id = ExtractURLAttribute(InURL, "id=\"", '"'); string secret = ExtractURLAttribute(InURL, "secret=\"", '"'); string server = ExtractURLAttribute(InURL, "server=\"", '"'); string farm = ExtractURLAttribute(InURL, "farm=\"", '"');
-
将所有内容组合成最终字符串:
std::string Res = std::string( "http://farm" ) + farm + std::string( ".staticflickr.com/" ) + server + std::string( "/" ) + id + std::string( "_" ) + secret; std::string Fmt = "";
-
向结果字符串添加后缀,以确定请求照片的大小,并添加
.jpg
扩展名:if ( ImgSizeType == PHOTO_SIZE_128 ) { Fmt = "t"; } else if ( ImgSizeType == PHOTO_SIZE_256 ) { Fmt = "m"; } else if ( ImgSizeType == PHOTO_SIZE_512 ) { Fmt = "-"; } else if ( ImgSizeType == PHOTO_SIZE_1024 ) { Fmt = "b"; } else if ( ImgSizeType == PHOTO_SIZE_ORIGINAL ) { Fmt = "b"; }; return Res + std::string( "_" ) + Fmt + std::string( ".jpg" ); }
-
对于 Picasa,我们通过插入不同的代码路径来修改列表中的图片 URL:
std::string Picasa_GetDirectImageURL( const std::string& InURL, int ImgSizeType ) { std::string Fmt = ""; if ( ImgSizeType == PHOTO_SIZE_128 ) { Fmt = "/s128/"; } else if ( ImgSizeType == PHOTO_SIZE_256 ) { Fmt = "/s256/"; } else if ( ImgSizeType == PHOTO_SIZE_512 ) { Fmt = "/s512/"; } else if ( ImgSizeType == PHOTO_SIZE_1024 ) { Fmt = "/s1024/"; } else if ( ImgSizeType == PHOTO_SIZE_ORIGINAL ) { Fmt = "/s1600/"; }; size_t spos = InURL.find( "/s1600/" ); if ( spos == std::string::npos ) { return ""; } const size_t Len = strlen("/s1600/"); return InURL.substr( 0, spos ) + Fmt + InURL.substr( spos+Len, InURL.length()-spos-Len ); }
-
当我们需要同一张图片的不同分辨率时,我们提供了类型为
PhotoSize
的ImgSizeType
参数,它可以取以下值:enum PhotoSize { PHOTO_SIZE_128 = 0, PHOTO_SIZE_256 = 1, PHOTO_SIZE_512 = 2, PHOTO_SIZE_1024 = 3, PHOTO_SIZE_ORIGINAL = 4 };
-
这些值与 Flickr 或 Picasa 的命名约定无关,仅内部方便使用(且与 API 独立)。
工作原理…
我们有来自上一个食谱的图片列表:
std::vector<std::string> Images;
void Picasa_ParseXMLResponse( Response, Images);
然后,对于第一张图片的 URL:
ImageURL = Picasa_GetDirectImageURL(Images[0],
PHOTO_SIZE_128);
最后,使用下载器获取位于ImageURL
的图片。
还有更多…
Flickr 和 Picasa 网站都有一套规则,禁止大规模自动下载全尺寸图片(每秒不超过一张),我们开发的应用程序应严格遵守这些规则。
这个食谱代码的一个好处是,它可以被修改以支持知名的Yandex.Fotki
照片网站或其他类似的提供 RSS 订阅的照片托管服务。我们将其留给读者作为一个自助练习。
执行跨平台多线程操作
为了继续提升用户体验,我们应该使长时间运行的任务异步化,并对其执行进行细粒度控制。为此,我们在操作系统线程之上实现了一个抽象层。
准备就绪
Android NDK 线程基于 POSIX 线程。查看你的 NDK 文件夹中的platforms\android-14\arch-arm\usr\include\pthread.h
头文件。
如何操作...
-
我们从线程句柄类型的声明开始:
#ifndef _WIN32 #include <pthread.h> typedef pthread_t thread_handle_t; typedef pthread_t native_thread_handle_t; #else #include <windows.h> typedef uintptr_t thread_handle_t; typedef uintptr_t native_thread_handle_t; #endif
-
然后,我们声明线程接口:
class iThread { public: iThread::iThread():FThreadHandle( 0 ), FPendingExit(false) {} virtual ~iThread() {} void Start(); void Exit( bool Wait ); bool IsPendingExit() const { return FPendingExit; }; protected: virtual void Run() = 0;
-
Windows 和 Android 的入口点原型在返回类型上有所不同:
#ifdef _WIN32 static unsigned int __stdcall EntryPoint( void* Ptr ); #else static void* EntryPoint( void* Ptr ); #endif native_thread_handle_t GetCurrentThread(); private: volatile bool FPendingExit; thread_handle_t FThreadHandle; };
-
iThread::Start()
方法的可移植实现如下:void iThread::Start() { void* ThreadParam = reinterpret_cast<void*>( this ); #ifdef _WIN32 unsigned int ThreadID = 0; FThreadHandle = ( uintptr_t )_beginthreadex( NULL, 0, &ThreadStaticEntryPoint, ThreadParam, 0, &ThreadID ); #else pthread_create( &FThreadHandle, NULL, ThreadStaticEntryPoint, ThreadParam ); pthread_detach( FThreadHandle ); #endif }
工作原理...
为了演示实现的线程类的使用,我们定义了一个每秒输出一条消息的新线程:
class TestThread: public iThread
{
public:
virtual void Run()
{
printf("Test\n");
Sleep(1000);
}
};
void Test()
{
TestThread* Thread = new TestThread();
Thread->Start();
while (true) {}
}
现在,用 C++实现一个简单的多线程应用程序并不比用 Java 难多少。
同步跨平台的本地线程
为了防止不同线程同时访问共享资源,需要进行同步。访问共享资源的一段代码——不能被多个线程同时访问——被称为关键段(en.wikipedia.org/wiki/Critical_section
)。为了避免竞态条件,在关键段的入口和出口需要一种机制。在 Windows 应用程序中,关键段是 WinAPI 的一部分,在 Android 中,我们使用pthread
库中的互斥锁,它们起到相同的作用。
准备工作
Android 的原生同步原语是基于 POSIX 的。它们包括线程管理函数、互斥锁、条件变量和屏障。查看你的 NDK 文件夹中的platforms\android-14\arch-arm\usr\include\pthread.h
头文件。
如何操作...
-
让我们创建一个与 API 无关的线程同步抽象:
class Mutex { public: Mutex() { #if defined( _WIN32 ) InitializeCriticalSection( &TheCS ); #else pthread_mutex_init( &TheMutex, NULL ); #endif } ~Mutex() { #if defined( _WIN32) DeleteCriticalSection( &TheCS ); #else pthread_mutex_destroy( &TheMutex ); #endif }
-
在 Windows 和 Android 中锁定和解锁互斥锁也是不同的:
void Lock() const { #if defined( _WIN32 ) EnterCriticalSection( (CRITICAL_SECTION*)&TheCS ); #else pthread_mutex_lock( &TheMutex ); #endif } void Unlock() const { #if defined( _WIN32 ) LeaveCriticalSection( (CRITICAL_SECTION*)&TheCS ); #else pthread_mutex_unlock( &TheMutex ); #endif } #if defined( _WIN32 ) CRITICAL_SECTION TheCS; #else mutable pthread_mutex_t TheMutex; #endif };
工作原理...
使用资源获取即初始化(RAII)的 C++习惯用法,我们可以定义Lock
类:
class Lock
{
public:
explicit Lock( const clMutex* Mutex ) : FMutex( Mutex )
{ FMutex->Lock(); };
~Lock() { FMutex->Unlock(); };
private:
const Mutex* FMutex;
};
然后,使用互斥锁就很直接了:
Lock( &SomeMutex );
在本书的后续章节中,几乎到处都广泛使用了互斥锁。
另请参阅
- 实现异步任务队列
使用引用计数管理内存
在本地代码环境中工作时,每个内存分配事件都由开发者处理。在多线程环境中跟踪所有分配变得异常困难。C++语言提供了一种避免使用智能指针手动对象析构的方法。由于我们正在开发移动应用程序,我们不能仅仅为了包含智能指针而使用整个Boost库。
注意
你可以在 Android NDK 中使用 Boost 库。我们在小型示例中避免使用它的主要原因有两个:编译时间大幅增加以及展示如何自己实现基本事物。如果你的项目已经包含了 Boost,建议你使用该库中的智能指针。编译过程简单,不需要特殊的移植步骤。
准备工作
我们需要一个简单的侵入式计数器,嵌入到我们所有引用计数类中。这里,我们提供了一个此类计数器的轻量级实现:
class iObject
{
public:
iObject(): FRefCounter(0) {}
virtual ~iObject() {}
void IncRefCount()
{
#ifdef _WIN32
return InterlockedIncrement( &FRefCounter );
#else
return __sync_fetch_and_add( &FRefCounter, 1 );
#endif
}
void DecRefCount()
{
#ifdef _WIN32
if ( InterlockedDecrement( &FRefCounter ) == 0 )
#else
if ( __sync_sub_and_fetch( Value, 1 ) == 0 )
#endif
{ delete this; }
}
private:
volatile long FRefCounter;
};
此代码在 Windows、Android 以及其他使用 gcc
或 clang
工具链的系统中可移植。
如何操作...
-
我们侵入式智能指针类的实现如下:
template <class T> class clPtr { public: clPtr(): FObject( 0 ) {} clPtr( const clPtr& Ptr ): FObject( Ptr.FObject ) {
-
在这里,我们调用一个助手来进行侵入式计数器的原子递增。这使得我们可以使用此智能指针处理不完整类型:
LPtr::IncRef( FObject ); } template <typename U> clPtr( const clPtr<U>& Ptr ): FObject( Ptr.GetInternalPtr() ) { LPtr::IncRef( FObject ); } ~clPtr() {
-
同样的技巧也应用于原子减量操作:
LPtr::DecRef( FObject ); }
-
我们需要一个构造函数,用于从
T*
进行隐式类型转换:clPtr( T* const Object ): FObject( Object ) { LPtr::IncRef( FObject ); }
-
我们还需要一个赋值运算符:
clPtr& operator = ( const clPtr& Ptr ) { T* Temp = FObject; FObject = Ptr.FObject; LPtr::IncRef( Ptr.FObject ); LPtr::DecRef( Temp ); return *this; }
-
解引用运算符(
->
)是任何智能指针的关键特性之一:inline T* operator -> () const { return FObject; }
-
模仿
dynamic_cast
的行为:template <typename U> inline clPtr<U> DynamicCast() const { return clPtr<U>( dynamic_cast<U*>( FObject ) ); }
-
还实现了比较运算符:
template <typename U> inline bool operator == ( const clPtr<U>& Ptr1 ) const { return FObject == Ptr1.GetInternalPtr(); }
-
有时,我们需要将智能指针的值传递给第三方 C API。为此,我们需要获取内部指针:
inline T* GetInternalPtr() const { return FObject; } private: T* FObject; };
请参考书中补充材料中的示例 4_ReferenceCounting_ptr
以获取完整的源代码。
工作原理...
下面是一个演示我们智能指针使用方法的简约示例:
class SomeClass: public iObject {};
void Test()
{
clPtr<SomeClass> Ptr = new SomeClass();
}
SomeClass
的分配对象被赋值给智能指针 Ptr
。在 Test()
结尾,智能指针自动销毁,分配对象引用数变为零。因此,通过 delete()
调用隐式销毁分配对象,从而避免内存泄漏。
还有更多...
我们广泛检查我们的智能指针非空,并且我们希望使用如下传统语法:
if ( SomeSmartPointer ) ...
这可以在不向另一个可用类型添加转换运算符的情况下实现。以下是使用私有内部类完成的方式:
private:
class clProtector
{
private:
void operator delete( void* );
};
public:
inline operator clProtector* () const
{
if ( !FObject ) return NULL;
static clProtector Protector;
return &Protector;
}
基本上,条件 if ( SomeSmartPointer )
会将智能指针转换为指向 clProtector
类的指针。然而,C++编译器将防止你误用它。clProtector
的 operator delete( void* )
应该声明但不要定义,防止用户创建 clProtector
的实例。
智能指针常见的一个问题就是循环引用问题。当对象A
持有一个指向对象B
的引用,同时对象B
也持有一个指向对象A
的引用时,这两个对象的引用计数都不能为零。对于容器类来说,这种情况很常见,可以通过使用指向包含对象的原始指针而不是智能指针来避免。以下代码就是一个例子:
class SomeContainer;
class SomeElement: public iObject
{
指向父对象的原始指针:
SomeContainer* Parent;
};
class SomeContainer: public iObject
{
被垃圾收集的元素列表:
std::vector< clPtr<SomeElement> > Elements;
};
另请参阅
- 实现异步任务队列
实现异步任务队列
我们希望从主线程异步执行一系列任务,但保持它们之间的相对顺序。让我们为此实现一个任务队列。
准备就绪
我们需要前一个食谱中的互斥量和智能指针来做这件事,因为队列需要同步原语来保持其内部数据结构的一致性,并且需要智能指针来防止任务泄露。
如何操作...
-
我们想要放入工作线程的任务接口如下:
class iTask: public iObject { public: iTask() : FIsPendingExit(false) , FTaskID(0) , FPriority(0) {};
-
Run()
方法包含了我们任务的有效载荷。所有有用的工作都在这里完成:virtual void Run() = 0;
-
由于外部代码不知道任务的当前状态以及它现在正在做什么样的工作,因此不能从外部安全地终止任务。所以,
Exit()
方法只是设置一个适当的标志,这意味着我们想要退出:virtual void Exit() { FIsPendingExit = true; }
-
我们可以在
Run()
方法内部通过调用IsPendingExit()
来检查这个标志:virtual bool IsPendingExit() const volatile { return FIsPendingExit; }
-
任务应该能够相互区分。这就是 ID 的作用:
virtual void SetTaskID( size_t ID ) { FTaskID = ID; }; virtual size_t GetTaskID() const { return FTaskID; }; private: volatile bool FIsPendingExit; size_t FTaskID; };
-
这里是工作线程的接口(完整的实现可以在本书的下载包中找到):
class WorkerThread: public iThread { public:
-
我们可以随意入队和取消任务:
virtual void AddTask( const clPtr<iTask>& Task ); virtual bool CancelTask( size_t ID ); virtual void CancelAll(); …
-
ExtractTask()
私有方法用于原子地访问任务列表:private: clPtr<iTask> ExtractTask(); clPtr<iTask> FCurrentTask; private: std::list< clPtr<iTask> > FPendingTasks; tthread::mutex FTasksMutex; tthread::condition_variable FCondition; };
工作原理...
我们启动一个单独的工作线程并运行一个简单任务。与运行三个独立线程的关键区别在于,所有任务都是顺序执行的,并且一个公共资源(在我们的例子中是输出窗口)也是顺序使用,无需处理并发访问:
class TestTask: public iTask
{
public:
virtual void Run()
{
printf("Test\n");
}
};
int main()
{
WorkerThread* wt = new WorkerThread();
wt->Start( iThread::Priority_Normal );
逐个添加三个任务:
wt->AddTask( new TestTask() );
wt->AddTask( new TestTask() );
wt->AddTask( new TestTask() );
任务永远不会并行执行,而是顺序执行。使用一个简单的自旋锁来等待所有任务的完成:
while (wt->GetQueueSize() > 0) {}
return 0;
}
处理异步回调调用
在多线程编程中,我们可能会遇到的一个简单情况是需要在另一个线程上运行一个方法。例如,当工作线程上的下载任务完成时,主线程可能希望被通知任务完成,以解析下载的数据。在本食谱中,我们将实现这样的通知机制。
准备就绪
在我们继续实现细节之前,理解异步事件的概念很重要。当我们说异步时,我们指的是某件事发生不可预测并且没有确定的时间。例如,我们无法预测下载 URL 需要多长时间——这就是任务;任务异步完成并应异步调用回调。
如何操作…
-
对我们来说,消息应该是一个方法调用。我们将一个方法调用隐藏在这个接口后面:
class iAsyncCapsule: public iObject { public: virtual void Invoke() = 0; };
-
此类型的实例指针表示一个准备好的方法调用。我们定义了一个
iAsyncCapsule
队列,以下是其实现:class AsyncQueue { public: AsyncQueue(): FDemultiplexerMutex() , FCurrentQueue( 0 ) , FAsyncQueues( 2 ) , FAsyncQueue( &FAsyncQueues[0] ) { }
-
入队一个事件:
void EnqueueCapsule( const clPtr<iAsyncCapsule>& Capsule ) { LMutex Mutex( &FDemultiplexerMutex ); FAsyncQueue->push_back( Capsule ); }
-
如
Reactor
模式(en.wikipedia.org/wiki/Reactor_pattern
)中描述的事件多路分解器:void DemultiplexEvents() { CallQueue* LocalQueue = &FAsyncQueues[ FCurrentQueue ]; { LMutex Lock( &FDemultiplexerMutex );
-
这是一个奇偶技巧,用来防止复制整个队列。我们保留两个队列并在它们之间切换:
FCurrentQueue = ( FCurrentQueue + 1 ) % 2; FAsyncQueue = &FAsyncQueues[ FCurrentQueue ]; }
-
注意上面互斥锁的作用域。我们在互斥锁锁定时不应调用回调:
for ( CallQueue::iterator i = LocalQueue->begin(); i != LocalQueue->end(); ++i ) (*i)->Invoke(); LocalQueue->clear(); } private: size_t FCurrentQueue; typedef std::vector< clPtr<iAsyncCapsule> > CallQueue; std::vector<CallQueue> FAsyncQueues; CallQueue* FAsyncQueue; Mutex FDemultiplexerMutex; };
工作原理…
我们启动两个线程。一个通过在无限循环中调用 DemultiplexEvents()
函数来处理传入事件:
class ResponseThread: public iThread, public AsyncQueue
{
public:
virtual void Run() { while (true) { DemultiplexEvents(); } }
};
ResponseThread* Responder;
另一个线程生成异步事件:
class RequestThread: public iThread
{
public:
virtual void Run()
{
while ( true )
{
Responder->EnqueueCapsule( new TestCall() );
Sleep(1000);
}
}
};
我们对事件的响应在 TestCall
类中实现:
class TestCall: public iAsyncCapsule
{
public:
virtual void Invoke() { printf("Test\n"); }
};
main()
函数启动两个线程并无限期等待(你可以按 Ctrl + Break 来停止它):
int main()
{
(Responder = new ResponseThread())->Start();
(new RequestThread())->Start();
while (true) {}
return 0;
}
你应该看到以下输出:
Test
Test
Test
…
printf()
函数可能不是线程安全的,但我们的队列确保对它的调用不会相互干扰。
异步处理网络工作
网络本质上是一组不可预测和异步的操作。让我们在单独的线程中异步执行,以防止在 UI 线程上发生停滞,这可能导致 Android 上的 ANR 行为。
准备就绪
这里,我们需要用到本章前面食谱中实现的所有内容:智能指针、工作线程、libcurl 下载器以及异步事件队列。
如何操作…
-
我们从
iTask
派生DownloadTask
类,它使用 libcurl 库执行 HTTP 请求。在这里,我们实现其方法Run()
,该方法设置 libcurl 库并执行网络操作:void DownloadTask::Run() { clPtr<DownloadTask> Guard( this ); CURL* C = curl_easy_init();
-
设置 libcurl 的参数:
curl_easy_setopt( C, CURLOPT_URL, FURL.c_str() ); curl_easy_setopt( C, CURLOPT_FOLLOWLOCATION, 1 ); curl_easy_setopt( C, CURLOPT_NOPROGRESS, false ); curl_easy_setopt( C, CURLOPT_FAILONERROR, true ); curl_easy_setopt( C, CURLOPT_MAXCONNECTS, 10 ); curl_easy_setopt( C, CURLOPT_MAXFILESIZE, DownloadSizeLimit ); curl_easy_setopt( C, CURLOPT_WRITEFUNCTION, &MemoryCallback ); curl_easy_setopt( C, CURLOPT_WRITEDATA, this ); curl_easy_setopt( C, CURLOPT_PROGRESSFUNCTION, &ProgressCallback ); curl_easy_setopt( C, CURLOPT_PROGRESSDATA, this ); curl_easy_setopt( C, CURLOPT_CONNECTTIMEOUT, 30 ); curl_easy_setopt( C, CURLOPT_TIMEOUT, 60 );
-
禁用 SSL 密钥验证:
curl_easy_setopt( C, CURLOPT_SSL_VERIFYPEER, 0 ); curl_easy_setopt( C, CURLOPT_SSL_VERIFYHOST, 0 );
-
同步执行网络操作。
curl_easy_perform()
调用会阻塞当前线程,直到从网络获取结果或发生错误:FCurlCode = curl_easy_perform( Curl );
-
读取结果并为库清理:
curl_easy_getinfo( Curl, CURLINFO_RESPONSE_CODE, &FRespCode ); curl_easy_cleanup( Curl );
-
告诉下载器为此任务调用完成回掉:
if ( FDownloader ) { FDownloader->CompleteTask( this ); } }
工作原理…
我们提供了一个代码片段,该片段从 Flickr 回声服务下载响应并在主线程上处理任务完成:
volatile bool g_ShouldExit = false;
class TestCallback: public DownloadCompleteCallback
{
public:
TestCallback() {}
将结果打印到控制台窗口:
virtual void Invoke()
{
printf("Download complete\n");
printf("%s\n", (unsigned char*)FResult->GetData());
g_ShouldExit = true;
}
};
int main()
{
Curl_Load();
iAsyncQueue* Events = new iAsyncQueue();
Downloader* d = new Downloader();
d->FEventQueue = Events;
…
d->DownloadURL(
"http://api.flickr.com/services/rest/?method=flickr.test.echo&name=value", 1, new TestCallback()
);
等待传入事件:
while (!g_ShouldExit)
{
Events->DemultiplexEvents();
}
…
}
另请参阅
- 从 Flickr 和 Picasa 下载图片
检测网络地址
要与网页服务器通信,我们需要指定其 IP 地址。在受限的移动环境中,向用户询问 IP 地址不方便,我们必须自己检测地址(且不涉及任何不可移植的代码)。在接下来的 App5
示例中,我们使用了来自 Windows API 的 GetAdaptersAddresses()
函数以及 POSIX 的 getifaddrs()
函数。Android 运行时库提供了自己的 getifaddrs()
实现,该实现包含在 App5
源文件中的 DetectAdapters.cpp
文件中。
准备就绪
让我们声明一个结构来保存描述网络适配器的信息:
struct sAdapterInfo
{
这是网络适配器的内部系统名称:
char FName[256];
适配器的 IP 地址如下:
char FIP[128];
适配器的唯一识别号码:
char FID[256];
};
如何操作…
-
我们在下面的代码中提供了 Android 版本的
Net_EnumerateAdapters()
函数的详细代码。它枚举了系统中可用的所有网络适配器:bool Net_EnumerateAdapters( std::vector<sAdapterInfo>& Adapters ) { struct ifaddrs* MyAddrs, *ifa; void* in_addr; char buf[64];
-
getifaddrs()
函数创建一个描述本地系统网络接口的结构链表:if ( getifaddrs( &MyAddrs ) != 0 ) { return false; } …
-
遍历链表:
for ( ifa = MyAddrs; ifa != NULL; ifa = ifa->ifa_next ) { if ( ( ifa->ifa_addr == NULL ) || !( ifa->ifa_flags & IFF_UP ) ) { continue; }
-
分别处理 IPv4 和 IPv6 地址:
switch ( ifa->ifa_addr->sa_family ) { case AF_INET: { in_addr = &( ( struct sockaddr_in* ) ifa->ifa_addr )->sin_addr; break; } case AF_INET6: { in_addr = &( ( struct sockaddr_in6* ) ifa->ifa_addr )->sin6_addr; break; } default: continue; }
-
将网络地址结构转换为 C 字符串,并将其保存在
Adapters
向量中:if ( inet_ntop( ifa->ifa_addr->sa_family,in_addr, buf, sizeof( buf ) ) ) { sAdapterInfo Info; strcpy( Info.FName, ifa->ifa_name ); strcpy( Info.FIP, buf ); sprintf( Info.FID, "%d", Idx ); Adapters.push_back( Info ); Idx++; } }
-
释放链表:
freeifaddrs( MyAddrs );
工作原理...
要在控制台窗口中枚举所有适配器,我们使用一个简单的循环:
int main()
{
std::vector<sAdapterInfo> a;
Net_EnumerateAdapters( a );
for(size_t i = 0 ; i < a.size() ; i++)
{
printf("[%d] %s\n", i + 1, a[i].FIP);
}
return 0;
}
这段代码的 Android 实现在App5
项目中。
还有更多...
幸运的是,上述代码适用于任何 POSIX 系统,App5
示例还提供了 Windows 版本的Net_EnumerateAdapters()
。在 Android 上,我们必须为我们的应用程序启用ACCESS_NETWORK_STATE
和INTERNET
权限;否则,系统将不允许我们访问互联网。这在App5
示例的AndroidManifest.xml
文件中完成,使用以下几行:
<uses-permission
android:name="android.permission.INTERNET"/>
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"/>
不要忘记将这些行放入打算与网络工作的应用程序的清单中。
编写 HTTP 服务器
在处理移动开发时,我们最终要在真实设备上运行游戏。在此之前,我们必须使用一些调试工具。当然,我们可能会设置远程调试gdb
,但当大多数与访问违规相关的关键错误被消除后,逻辑错误或与竞态条件相关的错误就会出现,这些问题难以追踪,并且需要多次对应用程序进行一些琐碎的更改并重新部署。为了能够直接在 Android 设备上快速更改应用程序的运行时行为,我们可以实现一个嵌入式 Web 服务器,并通过界面微调应用程序的一些内部参数。此配方包含了App5
的概要,它实现了这样的 Web 服务器。
准备就绪
从零开始编写 HTTP 服务器并不容易,因此我们使用了 René Nyffenegger 提供的免费简单服务器,可以从以下网页获取:www.adp-gmbh.ch/win/misc/webserver.html
。
我们直接使用这些资源的大部分,并在App5
示例中包含了支持 Android 的更精致版本。与原始版本最重要的区别在于,我们使用了基于WinSock和Android BSD套接字的抽象套接字 API。我们建议您仔细查看App5
源中的Sockets.h
和Sockets.cpp
文件。
如何操作…
-
HTTP 服务器在一个单独的线程上启动,该线程是
iThread
类的后代。服务器的主循环很简单:while ( !IsPendingExit() ) { LTCPSocket* NewSocket = in->Accept(); if ( NewSocket != 0 ) { // Add new thread HTTPRequestThread* T = new HTTPRequestThread(); T->FServer = this; T->FSocket = NewSocket; T->Start(); } }
-
我们等待传入的连接,当
Accept()
方法成功时,将启动一个新的HTTPRequestThread
。此线程从新创建的套接字读取数据并填充sHTTPServerRequest
结构。最后,在HandleRequest()
方法中处理此请求,通过填充sHTTPServerRequest::FData
字段来发送 HTML 页面的内容。最终,这些数据被发送到客户端。代码是线性的,但在这里呈现有些过长。我们建议读者查看HTTP.cpp
文件以了解详细信息。
工作原理…
为了使用这个服务器,我们在HTTP.cpp
文件中创建了HTTPServerThread
实例,并实现了SetVariableValue()
和GetVariableValue()
函数,默认情况下这些函数是空的。服务器启动代码位于OnStart()
函数中。
我们创建服务器实例:
g_Server = new HTTPServerThread();
然后,我们使用检测到的适配器地址:
if ( !Adapters.empty() )
{
g_Server->FBindAddress = Adapters[0].FIP;
}
最后,我们启动网页服务器线程:
g_Server->Start();
默认情况下,服务器启动的 IP 地址是127.0.0.1
,端口号是8080
。
在 Android 设备上启动App5
之后,我们可以通过任何桌面电脑的网页浏览器连接到它:只需输入其 IP 地址和端口号。IP 地址由网页服务器在启动时检测,并显示在设备屏幕顶部。
以下是我们的微型网页服务器输出的浏览器截图:
从桌面网页浏览器访问我们的 Android 网页服务器。
还有更多…
App5
既可以在 Windows 上运行,也可以在 Android 上运行,但与网络配置相关的细节需要注意。
如果我们使用 3G 或类似的蜂窝网络,很可能我们没有外部 IP 地址,因此为了让我们的网页服务器在浏览器中可见,我们应该坚持使用 Wi-Fi 连接。
另请参阅
- 从 Flickr 和 Picasa 下载图片
第四章:组织虚拟文件系统
*文件:可以写入、读取或同时进行两者的对象。文件具有某些属性,包括类型。常见的文件类型包括普通文件和目录。其他类型的文件,如符号链接,可能由实现支持。
*文件系统:一系列文件及其某些属性的集合。
(Boost 文档,
www.boost.org
)
在本章中,我们将涵盖以下内容:
-
抽象文件流
-
实现可移植的内存映射文件
-
实现文件写入器
-
使用内存文件
-
实现挂载点
-
列举.zip 档案中的文件
-
从.zip 压缩文件中解压文件
-
异步加载资源
-
存储应用程序数据
引言
文件是任何计算机系统的构建块。本章处理只读应用程序资源的可移植处理,并提供存储应用程序数据的方案。我们还使用第三章网络通信中的代码,组织从.zip
档案中异步加载资源。
让我们简要考虑本章所涉及的问题。第一个问题是访问应用程序数据文件。通常,桌面操作系统的应用程序数据与可执行文件位于同一文件夹中。在 Android 上,事情会变得有些复杂。应用程序文件被打包在.apk
文件中,我们根本无法使用标准的fopen()
类函数,或者std::ifstream
和std::ofstream
类。
第二个问题源于文件名和路径的不同规则。Windows 和基于 Linux 的系统使用不同的路径分隔符字符,并提供不同的低级文件访问 API。
第三个问题源于文件 I/O 操作很容易成为整个应用程序中最慢的部分。如果涉及到交互延迟,用户体验可能会出现问题。为了避免延迟,我们应该在单独的线程上执行 I/O 操作,并在另一个线程上处理Read()
操作的结果。为了实现这一点,我们拥有所需的所有工具,如第三章网络通信所述——工作线程、任务、互斥量和异步事件队列。
我们从抽象的 I/O 接口开始,实现可移植的.zip
档案处理方法,并继续进行异步资源加载。
抽象文件流
文件 I/O API 在 Windows 和 Android(POSIX)操作系统之间略有不同,我们必须将这些差异隐藏在一系列一致的 C++接口后面。我们在第二章移植通用库中编译的所有库都使用它们自己的回调和接口。为了统一它们,我们将在本章及后续章节中编写适配器。
准备工作
请确保你熟悉 UNIX 关于文件和内存映射的概念。维基百科可能是一个不错的起点(en.wikipedia.org/wiki/Memory-mapped_file
)。
如何操作...
-
从现在开始,我们的程序将使用以下简单的接口来读取输入数据。基类
iObject
用于向此类实例添加侵入式引用计数器:class iIStream: public iObject { public: virtual void Seek( const uint64 Position ) = 0; virtual uint64 Read( void* Buf, const uint64 Size ) = 0; virtual bool Eof() const = 0; virtual uint64 GetSize() const = 0; virtual uint64 GetPos() const = 0;
以下是一些利用内存映射文件的方法:
virtual const ubyte* MapStream() const = 0; virtual const ubyte* MapStreamFromCurrentPos() const = 0; };
此接口支持使用
MapStream()
和MapStreamFromCurrentPos()
成员函数进行内存映射访问,以及使用BlockRead()
和Seek()
方法的顺序访问。 -
为了将一些数据写入存储,我们使用了如下的输出流接口(同样,基类
iObject
用于添加引用计数器):class iOStream: public iObject { public: virtual void Seek( const uint64 Position ) = 0; virtual uint64 GetFilePos() const = 0; virtual uint64 Write( const void* B, const uint64 Size ) = 0; };
-
iIStream
接口的Seek()
、GetFileSize()
、GetFilePos()
以及与文件名相关的方法可以在一个名为FileMapper
的单一类中实现:class FileMapper: public iIStream { public: explicit FileMapper( clPtr<iRawFile> File ); virtual ~FileMapper(); virtual std::string GetVirtualFileName() const{ return FFile->GetVirtualFileName(); } virtual std::string GetFileName() const{ return FFile->GetFileName(); }
-
从此流中读取连续的数据块,并返回实际读取的字节数:
virtual uint64 BlockRead( void* Buf, const uint64 Size ) { uint64 RealSize =( Size > GetBytesLeft() ) ? GetBytesLeft() : Size;
-
如果我们已经读取了所有内容,则返回零:
if ( RealSize < 0 ) { return 0; } memcpy( Buf, ( FFile->GetFileData() + FPosition ),static_cast<size_t>( RealSize ) );
-
前进当前位置并返回复制的字节数:
FPosition += RealSize; return RealSize; } virtual void Seek( const uint64 Position ) { FPosition = Position; } virtual uint64 GetFileSize() const { return FFile->GetFileSize(); } virtual uint64 GetFilePos() const { return FPosition; } virtual bool Eof() const { return ( FPosition >= GetFileSize() ); } virtual const ubyte* MapStream() const { return FFile->GetFileData(); } virtual const ubyte* MapStreamFromCurrentPos() const { return ( FFile->GetFileData() + FPosition ); } private: clPtr<iRawFile> FFile; uint64 FPosition; };
-
FileMapper
使用以下iRawFile
接口来抽象数据访问:class iRawFile: public iObject { public: iRawFile() {}; virtual ~iRawFile() {}; void SetVirtualFileName( const std::string& VFName );void SetFileName( const std::string& FName );std::string GetVirtualFileName() const; std::string GetFileName(); virtual const ubyte* GetFileData() const = 0; virtual uint64 GetFileSize() const = 0; protected: std::string FFileName; std::string FVirtualFileName; };
除了这里实现的琐碎的GetFileName()
和SetFileName()
方法,在以下食谱中,我们实现了GetFileData()
和GetFileSize()
方法。
它是如何工作的...
iIStream::BlockRead()
方法在处理不可查找流时非常有用。为了尽可能快地访问,我们使用了以下食谱中实现的内存映射文件。MapStream()
和MapStreamFromCurrentPos()
方法旨在方便地提供对内存映射文件的访问。这些方法返回一个指向内存的指针,你的文件或文件的一部分就在这里映射。iOStream::Write()
方法与标准的ofstream::write()
函数类似。有关此食谱及以下食谱的完整源代码,请参考项目1_AbstractStreams
。
还有更多...
在为多个平台编程时,对我们来说,在 Windows 和基于 Linux 的 Android 上,文件名转换是一个重要的问题。
我们定义了以下PATH_SEPARATOR
常量,使用特定于操作系统的宏,以以下方式确定路径分隔符字符:
#if defined( _WIN32 )
const char PATH_SEPARATOR = '\\';
#else
const char PATH_SEPARATOR = '/';
#endif
以下简单的函数帮助我们确保为我们的操作系统使用有效的文件名:
inline std::string Arch_FixFileName(const std::string& VName)
{
std::string s( VName );
std::replace( s.begin(), s.end(), '\\', PATH_SEPARATOR );
std::replace( s.begin(), s.end(), '/', PATH_SEPARATOR );
return s;
}
另请参阅
-
实现可移植的内存映射文件
-
使用内存中文件
实现可移植的内存映射文件
现代操作系统提供了一个强大的机制,称为内存映射文件。简而言之,它允许我们将文件内容映射到应用程序地址空间。在实践中,这意味着我们可以像使用普通数组一样处理文件,并使用 C 指针访问它们。
准备就绪
为了理解前一个食谱中接口的实现,我们建议阅读有关内存映射的内容。在 MSDN 页面可以找到此机制在 Windows 中的实现概述,链接为:msdn.microsoft.com/en-us/library/ms810613.aspx
。
要了解更多关于内存映射的信息,读者可以参考 mmap()
函数的文档。
如何操作...
-
在 Windows 中,内存映射文件是通过
CreateFileMapping()
和MapViewOfFile()
API 调用创建的。Android 使用mmap()
函数,其工作方式几乎相同。这里我们声明实现了iRawFile
接口的RawFile
类。RawFile
持有一个指向内存映射文件的指针及其大小:ubyte* FFileData; uint64 FSize;
-
对于 Windows 版本,我们使用两个句柄分别指向文件和内存映射对象,而对于 Android,我们只使用文件句柄:
#ifdef _WIN32 HANDLE FMapFile; HANDLE FMapHandle; #else int FFileHandle; #endif
-
我们使用以下函数来打开文件并创建内存映射:
bool RawFile::Open( const string& FileName,const string& VirtualFileName ) {
-
首先,我们需要获取与文件关联的有效文件描述符:
#ifdef OS_WINDOWS FMapFile = (void*)CreateFileA( FFileName.c_str(),GENERIC_READ, FILE_SHARE_READ,NULL, OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL | FILE_FLAG_RANDOM_ACCESS,NULL ); #else FFileHandle = open( FileName.c_str(), O_RDONLY ); if ( FFileHandle == -1 ) { FFileData = NULL; FSize = 0; } #endif
-
使用文件描述符,我们可以创建一个文件映射。这里为了清晰起见,我们省略了错误检查。但是,补充材料中的示例包含了更多的错误检查:
#ifdef OS_WINDOWS FMapHandle = (void*)CreateFileMapping( ( HANDLE )FMapFile,NULL, PAGE_READONLY, 0, 0, NULL ); FFileData = (Lubyte*)MapViewOfFile((HANDLE)FMapHandle,FILE_MAP_READ, 0, 0, 0 ); DWORD dwSizeLow = 0, dwSizeHigh = 0; dwSizeLow = ::GetFileSize( FMapFile, &dwSizeHigh ); FSize = ((uint64)dwSizeHigh << 32) | (uint64)dwSizeLow; #else struct stat FileInfo; fstat( FFileHandle, &FileInfo ); FSize = static_cast<uint64>( FileInfo.st_size ); FFileData = (Lubyte*) mmap(NULL, FSize, PROT_READ,MAP_PRIVATE, FFileHandle, 0 ); close( FFileHandle ); #endif return true; }
-
正确的逆初始化函数会关闭所有的句柄:
bool RawFile::Close() { #ifdef OS_WINDOWS if ( FFileData ) UnmapViewOfFile( FFileData ); if ( FMapHandle ) CloseHandle( (HANDLE)FMapHandle ); CloseHandle( (HANDLE)FMapFile ); #else if ( FFileData ) munmap( (void*)FFileData, FSize ); #endif return true; }
-
iRawFile
接口的主要函数GetFileData
和GetFileSize
在这里有简单的实现:virtual const ubyte* GetFileData() { return FFileData; } virtual uint64 GetFileSize() { return FSize; }
它是如何工作的...
要使用 RawFile
类,我们需要创建一个实例并将其包裹进 FileMapper
类的实例中:
clPtr<RawFile> F = new RawFile();
F->Open("SomeFileName");
clPtr<FileMapper> FM = new FileMapper(F);
FM
对象可以与任何支持 iIStream
接口的功能一起使用。我们所有的 iRawFile
实现层次结构如下所示:
实现文件写入器
通常情况下,我们的应用程序可能希望将其一些数据存储在磁盘上。我们已经遇到过的另一个典型用例是从网络下载文件到内存缓冲区。这里,我们为普通文件和内存文件实现了 iOStream
接口的两种变体。
如何操作...
-
让我们从
iOStream
接口派生FileWriter
类。我们在iOStream
接口的基础上添加了Open()
和Close()
成员函数,并仔细实现了Write()
操作。我们的输出流实现不使用内存映射文件,而是使用普通文件描述符,如下代码所示:class FileWriter: public iOStream { public: FileWriter(): FPosition( 0 ) {} virtual ~FileWriter() { Close(); } bool Open( const std::string& FileName ) { FFileName = FileName;
-
我们使用定义分割 Android 和 Windows 特定的代码路径:
#ifdef _WIN32 FMapFile = CreateFile( FFileName.c_str(),GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS,FILE_ATTRIBUTE_NORMAL, NULL ); return !( FMapFile == ( void* )INVALID_HANDLE_VALUE ); #else FMapFile = open( FFileName.c_str(), O_WRONLY|O_CREAT ); FPosition = 0; return !( FMapFile == -1 ); #endif }
-
同样的技术在其他方法中也被使用。两个操作系统之间的差异微不足道,因此我们决定将所有内容放在一个单一类别中,并通过定义来分隔代码:
void Close() { #ifdef _WIN32 CloseHandle( FMapFile ); #else if ( FMapFile != -1 ) { close( FMapFile ); } #endif } virtual std::string GetFileName() const { return FFileName; } virtual uint64 GetFilePos() const { return FPosition; } virtual void Seek( const uint64 Position ) { #ifdef _WIN32 SetFilePointerEx( FMapFile,*reinterpret_cast<const LARGE_INTEGER*>( &Position ),NULL, FILE_BEGIN ); #else if ( FMapFile != -1 ) { lseek( FMapFile, Position, SEEK_SET ); } #endif FPosition = Position; }
注意
然而,如果你决定支持更多的操作系统,事情可能会变得更加复杂。这将是一个很好的重构练习。
virtual uint64 Write( const void* Buf, const uint64 Size ) { #ifdef _WIN32 DWORD written; WriteFile( FMapFile, Buf, DWORD( Size ),&written, NULL ); #else if ( FMapFile != -1 ) { write( FMapFile, Buf, Size ); } #endif FPosition += Size; return Size; } private: std::string FFileName; #ifdef _WIN32 HANDLE FMapFile; #else int FMapFile; #endif uint64 FPosition; };
它是如何工作的…
现在我们还可以展示一个将所有内容存储在内存块中的 iOStream
实现。为了在内存块中存储任意数据,我们声明了 Blob
类,如下代码所示:
class Blob: public iObject
{
public:
Blob();
virtual ~Blob();
将 Blob 数据指针设置到某个外部内存块:
void SetExternalData( void* Ptr, size_t Sz );
直接访问此 Blob 内的数据:
void* GetData();
…
获取 Blob 的当前大小:
size_t GetSize() const;
检查这个 Blob 是否负责管理它使用的动态内存:
bool OwnsData() const;
…
增加 Blob 的大小并向其中添加更多数据。这个方法在网络下载器中非常有用:
bool AppendBytes( void* Data, size_t Size );
…
};
这个类中还有很多其他方法。你可以在 Blob.h
文件中找到完整的源代码。我们使用这个 Blob
类,并声明了 MemFileWriter
类,它以下列方式实现我们的 iOStream
接口:
class MemFileWriter: public iOStream
{
public:
MemFileWriter(clPtr<Blob> Container);
改变文件内部的绝对位置,新数据将写入此处:
virtual void Seek( const uint64 Position )
{
if ( Position > FContainer->GetSize() )
{
检查我们是否允许调整 Blob 的大小:
if ( Position > FMaxSize - 1 ) { return; }
并尝试调整它的大小:
if ( !FContainer->SafeResize(static_cast<size_t>( Position ) + 1 ))
{ return; }
}
FPosition = Position;
}
将数据写入此文件的当前位置:
virtual uint64 Write( const void* Buf, const uint64 Size )
{
uint64 ThisPos = FPosition;
确保有足够的空间:
Seek( ThisPos + Size );
if ( FPosition + Size > FMaxSize ) { return 0; }
void* DestPtr = ( void* )( &( ( ( ubyte* )(FContainer->GetData() ) )[ThisPos] ) );
写入实际数据:
memcpy( DestPtr, Buf, static_cast<size_t>( Size ) );
return Size;
}
}
private:
…
};
我们省略了 GetFileName()
、GetFilePos()
、GetMaxSize()
、SetContainer()
、GetContainer()
、GetMaxSize()
和 SetMaxSize()
成员函数的简单实现以及字段声明。你可以在本书的代码包中找到它们的完整源代码。
另请参阅
- 使用内存文件工作
使用内存文件工作
有时能够将某些任意的运行时生成的内存数据当作文件来处理非常方便。例如,考虑使用从照片托管服务下载的 JPEG 图像作为 OpenGL 纹理。我们不需要将其保存到内部存储中,因为这是浪费 CPU 时间。我们也不想编写从内存加载图像的单独代码。由于我们有抽象的 iIStream
和 iRawFile
接口,我们只需实现后者以支持内存块作为数据源。
准备就绪
在前面的食谱中,我们已经使用了 Blob
类,它是一个围绕 void*
缓冲区的简单包装。
如何操作...
-
我们的
iRawFile
接口包括两个方法:GetFileData()
和GetFileSize()
。我们只需将这些调用委托给Blob
的一个实例:class ManagedMemRawFile: public iRawFile { public: ManagedMemRawFile(): FBlob( NULL ) {} virtual const ubyte* GetFileData() const { return ( const ubyte* )FBlob->GetData(); } virtual uint64 GetFileSize() const { return FBlob->GetSize(); } void SetBlob( const clPtr<Blob>& Ptr ) { FBlob = Ptr; } private: clPtr<Blob> FBlob; };
-
有时避免使用
Blob
对象的开销很有用,为此我们提供了另一个类MemRawFile
,它持有一个指向内存块的原始指针,并可选地负责内存分配:class MemRawFile: public iRawFile { public: virtual const ubyte* GetFileData() const { return (const ubyte*) FBuffer; } virtual uint64 GetFileSize() const { return FBufferSize; } void CreateFromString( const std::string& InString ); void CreateFromBuffer( const void* Buf, uint64 Size ); void CreateFromManagedBuffer( const void* Buf, uint64 Size ); private: bool FOwnsBuffer; const void* FBuffer; uint64 FBufferSize; };
工作原理...
我们使用 MemRawFile
作为从 .zip
文件提取的内存块的适配器,以及 ManagedMemRawFile
作为从照片网站下载的数据的容器。
另请参阅
-
第三章, 网络
-
第六章, 统一 OpenGL ES 3 和 OpenGL3
实现挂载点
这样很方便,无论应用程序资源的实际来源如何——来自实际文件、磁盘上的 .zip
存档,还是通过网络下载的内存中存档——都可以像它们都在同一个文件夹树中一样访问。让我们为此类访问实现一个抽象层。
准备就绪
我们假设读者熟悉 NTFS 重解析点(en.wikipedia.org/wiki/NTFS_reparse_point
)、UNIX 符号链接(en.wikipedia.org/wiki/Symbolic_link
)和目录挂载过程(en.wikipedia.org/wiki/Mount_(Unix)
)的概念。
如何实现...
-
我们的文件夹树将由抽象的挂载点组成。一个单独的挂载点可以对应于现有操作系统文件夹的路径、磁盘上的
.zip
存档、.zip
存档内的路径,甚至可以表示已移除的网络路径。注意
尝试将建议的框架扩展到网络路径挂载点。
class iMountPoint: public iObject { public:
-
检查此挂载点下是否存在文件:
virtual bool FileExists( const string& VName ) const = 0;
-
将虚拟文件名(这是我们文件夹树中此文件的名称)转换为此挂载点后的完整文件名:
virtual string MapName( const string& VName ) const = 0;
-
我们需要创建一个文件阅读器,以便与
FileMapper
类一起使用,用于此挂载点内指定的虚拟文件:virtual clPtr<iRawFile> CreateReader(const string& Name ) const = 0; };
-
对于物理文件夹,我们提供了一个简单的实现,该实现创建
FileMapper
类的实例,并引用iRawFile
:class PhysicalMountPoint: public iMountPoint { public: explicit PhysicalMountPoint(const std::string& PhysicalName); virtual bool FileExists(const std::string& VirtualName ) const { return FS_FileExistsPhys( MapName( VirtualName ) ); } virtual std::string MapName(const std::string& VirtualName ) const { return ( FS_IsFullPath( VirtualName ) ) ?VirtualName : ( FPhysicalName + VirtualName ); }
-
创建一个阅读器以访问此挂载点内的数据:
virtual clPtr<iRawFile> CreateReader(const std::string& VirtualName ) const { std::string PhysName = FS_IsFullPath( VirtualName ) ?VirtualName : MapName( VirtualName ); clPtr<RawFile> File = new RawFile(); return !File->Open( FS_ValidatePath( PhysName ),VirtualName ) ? NULL : File; } private: std::string FPhysicalName; };
-
挂载点的集合将被命名为
FileSystem
,如下代码所示:class FileSystem: public iObject { public: void Mount( const std::string& PhysicalPath ); void AddAlias(const std::string& Src,const std::string& Prefix ); std::string VirtualNameToPhysical(const std::string& Path ) const; bool FileExists( const std::string& Name ) const; private: std::vector< clPtr<iMountPoint> > FMountPoints; };
工作原理...
MapName()
成员函数将给定的虚拟文件名转换成可以传递给 CreateReader()
方法的形式。
FS_IsFullPath()
函数检查路径是否以 Android 上的 /
字符或 Windows 上的 :\
子字符串开头。Str_AddTrailingChar()
函数确保我们在给定路径的末尾有一个路径分隔符。
FileSystem
对象充当挂载点的容器,并将文件阅读器的创建重定向到适当的点。Mount
方法确定挂载点的类型。如果 PhysicalPath
以 .zip
或 .apk
子字符串结尾,将创建 ArchiveMountPoint
类的实例,否则将实例化 PhysicalMountPoint
类。FileExists()
方法遍历活动的挂载点,并调用 iMountPoint::FileExists()
方法。VirtualNameToPhysical()
函数找到适当的挂载点,并调用 iMountPoint::MapName()
方法,以使文件名能够与底层操作系统 I/O 函数一起使用。这里我们省略了 FMountPoints
向量管理的琐碎细节。
还有更多...
使用我们的 FileSystem::AddAlias
方法,我们可以创建一个装饰文件名的特殊挂载点:
class AliasMountPoint: public iMountPoint
{
public:
AliasMountPoint( const clPtr<iMountPoint>& Src );
virtual ~AliasMountPoint();
设置别名路径:
void SetAlias( const std::string& Alias )
{
FAlias = Alias;
Str_AddTrailingChar( &FAlias, PATH_SEPARATOR );
}
…
virtual clPtr<iRawFile> CreateReader(const std::string& VirtualName ) const
{ return FMP->CreateReader( FAlias + VirtualName ); }
private:
设置一个前缀,以附加到此挂载点中的每个文件:
std::string FAlias;
设置一个指向隐藏在别名背后的另一个挂载点的指针:
clPtr<iMountPoint> FMP;
};
这个装饰器类会在任何传递给它的文件名前添加FAlias
字符串。这个简单的挂载点在同时为 Android 和 Windows 开发时很有用,因为在 Android 的.apk
文件中,文件位于比 Windows 开发文件夹更低的文件夹层次结构中。稍后我们确定 Android 应用程序所在的文件夹,并使用AliasMountPoint
类进行挂载。
作为提醒,以下是我们iMountPoint
接口及其实现的类图:
另请参阅
- 从
.zip
归档中解压缩文件
列举.zip
归档中的文件
要将.zip
文件的内容无缝地整合到我们的文件系统中,我们需要读取归档内容,并能够单独访问每个文件。由于我们正在开发自己的文件 I/O 库,我们使用iIStream
接口来访问.zip
文件。NDK 提供了一种从 C++应用程序读取.apk
资产的方法(请参阅 NDK 文件夹中的usr/include/android/asset_manager.h
)。然而,它仅在 Android 2.3 上可用,并且会使在桌面计算机(没有模拟器)上调试游戏中的文件访问变得更加复杂。为了使我们的本地代码可移植到之前的 Android 版本和其他移动操作系统,我们将构建自己的资产读取器。
注意
Android 应用程序作为.apk
包分发,这些包基本上只是重命名的.zip
归档,其中包含特殊的文件夹结构和元数据。
准备就绪
我们使用zlib
库和MiniZIP
项目来访问.zip
压缩文件的内容。最新版本可以从以下链接下载:www.winimage.com/zLibDll/minizip.html
。
如何操作...
-
zlib
库被设计为可扩展的。它并不假设每个开发者只使用fopen()
调用或std::ifstream
接口。要从我们自己的带有iIStream
接口的容器中读取数据,我们将iIStream
实例转换为void*
指针,并编写一组传递给zlib
的例程。这些例程类似于标准的fopen()
式接口,本质上只是将zlib
重定向到我们的iIStream
类:static voidpf ZCALLBACK zip_fopen( voidpf opaque,const void* filename, int mode ) { ( ( iIStream* )opaque )->Seek( 0 ); return opaque; }
-
从
.zip
文件中读取压缩数据。这种间接访问实际上允许访问其他归档中的归档:static uLong ZCALLBACK zip_fread( voidpf opaque, voidpf stream,void* buf, uLong size ) { iIStream* S = ( iIStream* )stream; int64_t CanRead = ( int64 )size; int64_t Sz = S->GetFileSize(); int64_t Ps = S->GetFilePos(); if ( CanRead + Ps >= Sz ) { CanRead = Sz - Ps; } if ( CanRead > 0 ) { S->BlockRead( buf, (uint64_t)CanRead ); } else { CanRead = 0; } return ( uLong )CanRead; }
-
返回
.zip
文件内的当前位置:static ZPOS64_T ZCALLBACK zip_ftell( voidpf opaque, voidpf stream ) { return ( ZPOS64_T )( ( iIStream* )stream )->GetFilePos(); }
-
移动到指定的位置。偏移值相对于当前位置(
SEEK_CUR
)、文件开始(SEEK_SET
)或文件结束(SEEK_END
):static long ZCALLBACK zip_fseek ( voidpf opaque, voidpf stream,ZPOS64_T offset, int origin ) { iIStream* S = ( iIStream* )stream; int64 NewPos = ( int64 )offset; int64 Sz = ( int64 )S->GetFileSize(); switch ( origin ) { case ZLIB_FILEFUNC_SEEK_CUR: NewPos += ( int64 )S->GetFilePos(); break; case ZLIB_FILEFUNC_SEEK_END: NewPos = Sz - 1 - NewPos; break; case ZLIB_FILEFUNC_SEEK_SET: break; default: return -1; } if ( NewPos >= 0 && ( NewPos < Sz ) ) { S->Seek( ( uint64 )NewPos ); } else { return -1; } return 0; }
-
我们没有关闭或处理错误,所以
fclose()
和ferror()
回调是空的:static int ZCALLBACK zip_fclose(voidpf op, voidpf s) { return 0; } static int ZCALLBACK zip_ferror(voidpf op, voidpf s) { return 0; }
-
最后,所有函数的指针存储在
zlib_filefunc64_def
结构中,该结构代替了MiniZIP
所有函数中的常规FILE*
。我们编写了一个简单的例程来填充这个结构,如下面的代码所示:void fill_functions( iIStream* Stream, zlib_filefunc64_def* f ) { f->zopen64_file = zip_fopen; f->zread_file = zip_fread; f->zwrite_file = NULL; f->ztell64_file = zip_ftell; f->zseek64_file = zip_fseek; f->zclose_file = zip_fclose; f->zerror_file = zip_ferror; f->opaque = Stream; }
-
实现了
fopen()
接口后,我们可以提供代码片段以枚举由iIStream
对象表示的归档中的文件。这是ArchiveReader
类中的两个基本函数之一:bool ArchiveReader::Enumerate_ZIP() { iIStream* TheSource = FSourceFile; zlib_filefunc64_def ffunc; fill_functions( TheSource, &ffunc ); unzFile uf = unzOpen2_64( "", &ffunc ); unz_global_info64 gi; int err = unzGetGlobalInfo64( uf, &gi );
-
遍历此归档中的所有文件:
for ( uLong i = 0; i < gi.number_entry; i++ ) { char filename_inzip[256]; unz_file_info64 file_info; err = unzGetCurrentFileInfo64( uf, &file_info,filename_inzip, sizeof( filename_inzip ),NULL, 0, NULL, 0 ); if ( err != UNZ_OK ) { break; } if ( ( i + 1 ) < gi.number_entry ) { err = unzGoToNextFile( uf ); }
-
将遇到的文件名存储在我们自己的结构体向量中:
sFileInfo Info; std::string TheName = Arch_FixFileName(filename_inzip); Info.FCompressedSize = file_info.compressed_size; Info.FSize = file_info.uncompressed_size; FFileInfos.push_back( Info ); FFileNames.push_back( TheName ); } unzClose( uf ); return true; }
-
sFileInfo
结构的数组存储在ArchiveReader
实例中:class ArchiveReader: public iObject { public: ArchiveReader(); virtual ~ArchiveReader();
-
分配源流并枚举文件:
bool OpenArchive( const clPtr<iIStream>& Source );
-
从归档中提取单个文件到
FOut
流中。这意味着我们可以直接将压缩文件提取到内存中:bool ExtractSingleFile( const std::string& FName,const std::string& Password,const clPtr<iOStream>& FOut );
-
释放所有资源,并可选择性地关闭源流:
bool CloseArchive();
-
检查归档中是否存在这样的文件:
bool FileExists( const std::string& FileName ) const { return ( GetFileIdx( FileName ) > -1 ); } …
-
以下代码是前一点提到的
sFileInfo
结构,它定义了文件在.zip
归档中的位置:struct sFileInfo {
-
首先,我们需要归档内部文件数据的偏移量:
uint64 FOffset;
-
然后,我们需要未压缩文件的大小:
uint64 FSize;
-
以及压缩文件的大小,让
zlib
库知道何时停止解码:uint64 FCompressedSize;
-
不要忘记指向压缩数据本身的指针:
void* FSourceData; }; … };
我们没有提供ArchiveReader
类的完整源代码,但鼓励您查看随附的源代码。第二个基本函数ExtractSingleFile()
将在下一个食谱中介绍。
工作原理...
我们使用ArchiveReader
类编写ArchiveMountPoint
,它提供了对.zip
文件内容的无缝访问:
class ArchiveMountPoint: public iMountPoint
{
public:
ArchiveMountPoint( const clPtr<ArchiveReader>& R );
创建一个读取器接口以访问归档的内容:
virtual clPtr<iRawFile> CreateReader(
const std::string& VirtualName ) const
{
std::string FName = Arch_FixFileName( VirtualName );
MemRawFile* File = new MemRawFile();
File->SetFileName( VirtualName );
File->SetVirtualFileName( VirtualName );
const void* DataPtr = FReader->GetFileData( FName );
uint64 FileSize = FReader->GetFileSize( FName );
File->CreateFromManagedBuffer( DataPtr, FileSize );
return File;
}
检查此归档挂载点内是否存在指定的文件:
virtual bool FileExists(const std::string& VirtualName ) const
{
return
FReader->FileExists(Arch_FixFileName(VirtualName));
}
virtual std::string MapName(const std::string& VirtualName ) const
{ return VirtualName; }
private:
clPtr<ArchiveReader> FReader;
};
ArchiveReader
类负责内存管理,并返回一个立即可用的MemRawFile
实例。
另请参阅
-
从.zip 压缩包中解压文件
-
第五章, 跨平台音频流
从.zip 压缩包中解压文件
我们有Enumerate_ZIP()
函数来遍历.zip
归档内的单个文件,现在是提取其内容的时候了。
准备就绪
这段代码使用了与前一个食谱相同的fopen()
类函数。
如何操作...
-
以下辅助函数负责文件提取,并在
ArchiveReader::ExtractSingleFile()
方法中使用:int ExtractCurrentFile_ZIP( unzFile uf,const char* password, const clPtr<iOStream>& fout ) { char filename_inzip[256]; int err = UNZ_OK; void* buf; uInt size_buf; unz_file_info64 file_info; err = unzGetCurrentFileInfo64( uf, &file_info,filename_inzip, sizeof( filename_inzip ),NULL, 0, NULL, 0 ); if ( err != UNZ_OK ) { return err; } uint64_t file_size = ( uint64_t )file_info.uncompressed_size; uint64_t total_bytes = 0; unsigned char _buf[WRITEBUFFERSIZE]; size_buf = WRITEBUFFERSIZE; buf = ( void* )_buf; if ( buf == NULL ) { return UNZ_INTERNALERROR; }
-
将提供的密码传递给
zlib
库:err = unzOpenCurrentFilePassword( uf, password );
-
以下是实际的解压缩循环:
do { err = unzReadCurrentFile( uf, buf, size_buf ); if ( err < 0 ) { break; } if ( err > 0 ) { total_bytes += err; fout->Write( buf, err ); } } while ( err > 0 ); int close_err = unzCloseCurrentFile ( uf ); … }
-
ExtractSingleFile()
函数负责从归档中提取单个文件到输出流中:bool ArchiveReader::ExtractSingleFile( const string& FName,const string& Password, const clPtr<iOStream>& FOut ) { int err = UNZ_OK; LString ZipName = FName; std::replace ( ZipName.begin(), ZipName.end(), '\\', '/' ); clPtr<iIStream> TheSource = FSourceFile; TheSource->Seek(0);
-
通过以下代码解压缩数据:
zlib_filefunc64_def ffunc; fill_functions( FSourceFile.GetInternalPtr(), &ffunc ); unzFile uf = unzOpen2_64( "", &ffunc ); if ( unzLocateFile( uf, ZipName.c_str(), 0) != UNZ_OK ) { return false; } err = ExtractCurrentFile_ZIP( uf,Password.empty() ? NULL : Password.c_str(), FOut ); unzClose( uf ); return ( err == UNZ_OK ); }
工作原理...
ExtractSingleFile()
方法使用了zlib
和MiniZIP
库。在随附材料中,我们包含了libcompress.c
和libcompress.h
文件,其中包含了合并的zlib
、MiniZIP
和libbzip2
源代码。
2_MountPoints
示例包含了test.cpp
文件,其中包含了遍历归档文件的代码:
clPtr<RawFile> File = new RawFile();
File->Open( "test.zip", "" );
clPtr<ArchiveReader> a = new ArchiveReader();
a->OpenArchive( new FileMapper(File) );
ArchiveReader
实例包含了有关test.zip
文件内容的所有信息。
异步加载资源
本书的序言告诉我们,在本章中我们将开发一个异步资源加载系统。我们已经为此完成了所有准备工作。我们现在配备了安全的内存管理、任务队列,以及最终带有归档文件支持的FileSystem
抽象。
现在我们想要做的是,将所有这些代码结合起来实现一个看似简单的事情:创建一个应用程序,渲染一个带有纹理的四边形,并在运行中更新其纹理。应用程序启动后,屏幕上出现一个白色四边形,然后,一旦纹理文件从磁盘加载,四边形的纹理就会改变。这相对容易做到——我们只需运行在这里实现的LoadImage
任务,一旦此任务完成,我们就在主线程上获得完成事件,主线程还拥有一个事件队列。我们不能只用一个互斥锁来更新纹理数据,因为在第六章,统一 OpenGL ES 3 和 OpenGL 3中使用 OpenGL 纹理对象时,所有渲染状态只能在创建纹理的同一个线程中改变——在我们的主线程中。
准备就绪
我们强烈建议你复习第三章,网络编程中提到的所有多线程技术。我们在这里使用的简单渲染技术已在App3
示例中介绍,该示例位于第三章,建立构建环境部分,以及在第二章,移植通用库中的App4
示例中。
如何操作...
-
在这里我们为资源管理奠定了基础。我们需要内存中存储位图的概念。它在
Bitmap
类中实现,如下代码所示:class Bitmap: public iObject { public: Bitmap( const int W, const int H) { size_t Size = W * H * 3; if ( !Size ) { return; } FWidth = W; FHeight = H; FBitmapData = (ubyte*)malloc( Size ); memset(FBitmapData, 0xFF, Size); } virtual ~Bitmap() { free(FBitmapData); } void Load2DImage( clPtr<iIStream> Stream ) { free( FBitmapData ); FBitmapData = read_bmp_mem(Stream->MapStream(), &FWidth, &FHeight ); } …
-
图像尺寸和原始像素数据设置如下:
int FWidth; int FHeight;
-
在这里我们使用 C 风格数组:
ubyte* FBitmapData; };
我们再次使用了第二章中的
read_bmp_mem()
函数,但这次内存缓冲区来自一个iIStream
对象。在第六章,统一 OpenGL ES 3 和 OpenGL 3中,我们添加了Texture
类来处理所有 OpenGL 复杂性,但现在我们只是渲染了一个Bitmap
类的实例。 -
接下来,我们实现异步加载操作:
class LoadOp_Image: public iTask { public: LoadOp_Image( clPtr<Bitmap> Bmp, clPtr<iIStream> IStream ):FBmp( Bmp ), FStream( IStream ) {} virtual void Run() { FBmp->Load2DImage( FStream ); g_Events->EnqueueCapsule(new LoadCompleteCapsule(FBmp) ); } private: clPtr<Bitmap> FBmp; clPtr<iIStream> FStream; };
-
LoadCompleteCapsule
类是一个派生自iAsyncCapsule
的类,它覆盖了Run()
方法:class LoadCompleteCapsule: public iAsyncCapsule { public: LoadCompleteCapsule(clPtr<Bitmap> Bmp): FBmp(Bmp) {} virtual void Invoke() { // … copy FBmp to g_FrameBuffer … } private: clPtr<Bitmap> FBmp; };
-
为了加载一个
Bitmap
对象,我们实现了以下函数:clPtr<Bitmap> LoadImg( const std::string& FileName ) { clPtr<iIStream> IStream = g_FS->CreateReader(FileName); clPtr<Bitmap> Bmp = new Bitmap(1, 1); g_Loader->AddTask( new LoadOp_Image( Bmp, IStream ) ); return Bmp; }
-
我们使用三个全局对象:文件系统
g_FS
、事件队列g_Events
和加载器线程g_Loader
。我们在程序开始时初始化它们。首先,我们启动FileSystem
:g_FS = new FileSystem(); g_FS->Mount(".");
-
iAsyncQueue
和WorkerThread
对象被创建,正如在第三章,网络通信中一样:g_Events = new iAsyncQueue(); g_Loader = new WorkerThread(); g_Loader->Start( iThread::Priority_Normal );
-
最后,我们可以加载位图:
clPtr<Bitmap> Bmp = LoadImg("test.bmp");
在这一点上,Bmp
是一个准备使用的对象,它将在另一个线程上自动更新。当然,使用 Bmp->FBitmapData
不是线程安全的,因为在我们读取它时可能会被销毁,或者只部分更新。为了克服这些困难,我们必须引入所谓的代理对象,我们在第六章,统一 OpenGL ES 3 和 OpenGL 3中使用它。
还有更多
完整的示例可以在 3_AsyncTextures
中找到。它实现了本章中描述的异步图像加载技术。
另请参阅
-
第五章, 跨平台音频流
-
第三章, 网络通信
存储应用程序数据
应用程序应该能够保存其临时和持久数据。有时数据应该写入外部存储器上的一个文件夹,其他应用程序可以访问该文件夹。让我们找出如何在 Android 和 Windows 上以可移植的方式获取此文件夹的路径。
准备就绪
如果你的 Android 智能手机在连接到台式电脑时卸载了外部存储,确保你断开连接并等待存储器重新挂载。
如何操作...
-
我们需要编写一些 Java 代码来完成这个任务。首先,我们会向
Environment
询问外部存储目录及其后缀,这样我们就可以将我们的数据与其他应用程序区分开来:protected String GetDefaultExternalStoragePrefix() { String Suffix = "/external_sd/Android/data/"; return Environment.getExternalStorageDirectory().getPath() +Suffix + getApplication().getPackageName(); }
注意
Suffix
值可以随意选择。你可以使用你希望的任何值。 -
这很简单;然而,我们必须执行一些额外的检查,以确保此路径确实存在。例如,在一些没有外部存储的设备上,它将不可用。
String ExternalStoragePrefix = GetDefaultExternalStoragePrefix(); String state = Environment.getExternalStorageState();
-
检查存储是否已挂载并且可以写入:
if ( !Environment.MEDIA_MOUNTED.equals( state ) ||Environment.MEDIA_MOUNTED_READ_ONLY.equals( state ) ) { ExternalStoragePrefix = this.getDir(getApplication().getPackageName(), MODE_PRIVATE).getPath(); }
-
检查存储是否可写:
try { new File( ExternalStoragePrefix ).mkdirs(); File F = new File(ExternalStoragePrefix + "/engine.log" ); F.createNewFile(); F.delete(); } catch (IOException e) { Log.e( "App6", "Falling back to internal storage" ); ExternalStoragePrefix = this.getDir(getApplication().getPackageName(), MODE_PRIVATE).getPath(); }
-
将路径传递给我们的 C++ 代码:
OnCreateNative( ExternalStoragePrefix ); public static native void OnCreateNative(StringExternalStorage);
工作原理...
本地代码以这种方式实现 JNI 调用 OnCreateNative()
:
extern std::string g_ExternalStorage;
extern "C"
{
JNIEXPORT void JNICALLJava_com_packtpub_ndkcookbook_app6_App6Activity_OnCreateNative(JNIEnv* env, jobject obj, jstring Path )
{
g_ExternalStorage = ConvertJString( env, Path );
OnStart();
}
}
还有一个小助手函数将 Java 字符串转换为 std::string
,我们会经常使用它:
std::string ConvertJString(JNIEnv* env, jstring str)
{
if ( !str ) std::string();
const jsize len = env->GetStringUTFLength(str);
const char* strChars = env->GetStringUTFChars(str,(jboolean *)0);
std::string Result(strChars, len);
env->ReleaseStringUTFChars(str, strChars);
return Result;
}
查看本书代码包中的应用程序 6_StoringApplicationData
。在 Android 上,它将输出类似于以下内容的行到系统日志:
I/App6 (27043): External storage path:/storage/emulated/0/external_sd/Android/data/com.packtpub.ndkcookbook.app6
在 Windows 上,它将以下内容打印到应用程序控制台:
External storage path: C:\Users\Author\Documents\ndkcookbook\App6
还有更多...
不要忘记将 WRITE_EXTERNAL_STORAGE
权限添加到你的 AndroidManifest.xml
,以便你的应用程序能够写入外部存储:
<uses-permissionandroid:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
否则,之前的代码将始终回退到内部存储。
另请参阅
- 第八章, 编写一个匹配-3 游戏
第五章:跨平台音频流
尝试关闭你最喜欢的游戏中的声音。 | ||
---|---|---|
--维克多·拉蒂波夫 |
本章我们将介绍以下内容:
-
初始化 OpenAL 并播放.wav 文件
-
抽象基本音频组件
-
流式声音
-
解码 Ogg Vorbis 文件
-
使用 ModPlug 解码跟踪器音乐
引言
我们在寻找一种真正可移植的实现方案,以在桌面电脑和移动设备上进行声音播放。我们建议使用 OpenAL 库,因为它在桌面端已经相当成熟,使用它将使得将现有游戏移植到 Android 更加容易。在本章中,我们将组织一个小型的多线程声音流媒体库。
音频播放本质上是一个异步过程,因此解码和控制声音硬件应该在单独的线程上完成,并从其他专用线程进行控制。例如,当玩家按下开火按钮,或者在街机游戏中一个角色撞击地面时,我们可能只是要求系统开始播放一个音频文件。在游戏中,这个操作的延迟通常不是很重要。
从数字角度来看,单声道或单声道声音(简称 mono),不过是表示连续信号的长时间一维数组。立体声或多声道声音由几个声道表示,并以交错数组的形式存储,其中一个声道的采样紧接着另一个声道的采样,依此类推。OpenAL 期望我们以一系列缓冲区的形式提交这些数据。OpenAL 库的主要概念包括设备、上下文、监听器、音频源和声音缓冲区:
在虚拟环境中产生的声音经过一系列滤波器处理后,通过扬声器播放。本章涵盖的内容将允许你为你的游戏创建一个可移植的音频子系统。
初始化 OpenAL 并播放.wav 文件
在这个食谱中,我们展示了播放未压缩音频文件的最简单示例,这些文件采用PCM格式(脉冲编码调制,en.wikipedia.org/wiki/Pulse-code_modulation
)。这个示例只是在无限循环中播放单个文件。我们将创建一个单一设备、一个单一设备上下文和一个音频源。所有这些都在一个专用线程中完成,但我们不应该担心多线程问题,因为 OpenAL 函数是保证线程安全的。
准备工作
OpenAL 库的源代码和构建脚本可以在0_OpenAL
文件夹中找到,本章的每个示例都包含预编译的静态库。对于 Windows,我们使用动态链接与 OpenAL。关于如何从 Android .apk
包中加载文件的说明可以在第四章,组织虚拟文件系统中找到。此配方的完整示例源代码可以在0_AL_On_Android
文件夹中找到。
如何操作...
-
SoundThread
类,我们在这个类中实现了实际播放,如下所示:class SoundThread: public iThread {
-
首先,我们声明 OpenAL 音频设备和设备上下文的句柄:
ALCdevice* FDevice; ALCcontext* FContext;
-
然后,我们声明 OpenAL 音频源和缓冲区的句柄:
ALuint FSourceID; ALuint FBufferID;
-
Run()
成员函数执行所有工作,包括初始化、反初始化以及将音频数据提交到 OpenAL:virtual void Run() {
-
我们初始化指向 OpenAL 函数的指针:
LoadAL();
-
然后,我们创建设备和设备上下文:
FDevice = alcOpenDevice( NULL ); FContext = alcCreateContext( FDevice, NULL );
-
最后,我们将新创建的设备上下文选为当前上下文:
alcMakeContextCurrent( FContext );
-
现在,我们开始创建音频源:
alGenSources( 1, &FSourceID );
-
我们设置一个常量最大播放音量为
1.0
,在 OpenAL 中这被称为增益:alSourcef( FSourceID, AL_GAIN, 1.0f );
-
为了听到声音,我们必须加载包含声音数据的文件:
clPtr<iIStream> Sound = g_FS->CreateReader("test.wav");
-
我们使用内存映射文件,并询问 iStream 对象关于文件大小:
int DataSize = (int)Sound->GetSize(); const ubyte* Data = Sound->MapStream();
-
为了避免处理完整的RIFF WAVE文件格式,我们准备了一个包含单个未压缩音频数据块的特定文件;此数据的格式是 22 kHz 单声道 16 位声音。我们传递
Data+sizeof(sWAVHeader)
作为音频数据,音频数据的大小显然是DataSize-sizeof(sWAVHeader)
:PlayBuffer( Data + sizeof( sWAVHeader ), DataSize - sizeof( sWAVHeader ));
-
然后,我们在自旋循环中调用
IsPlaying()
函数,以检测 OpenAL 何时停止播放声音:while ( IsPlaying() ) {}
-
一旦声音播放完成,我们就删除我们创建的所有对象:
alSourceStop( FSourceID ); alDeleteSources( 1, &FSourceID ); alDeleteBuffers( 1, &FBufferID ); alcDestroyContext( FContext ); alcCloseDevice( FDevice );
-
最后,在 Windows 上卸载 OpenAL 库:
UnloadAL();
-
在 Android 上,释放分配的资源并释放音频设备非常重要。否则,音频会在后台继续播放。为了避免在这个小示例中编写 Java 代码,我们只需通过
exit()
调用终止本地活动:exit( 0 ); }
-
上面的代码使用
IsPlaying()
函数来检查音频源是否忙碌:bool IsPlaying() { int State; alGetSourcei( FSourceID, AL_SOURCE_STATE, &State ); return State == AL_PLAYING; }
-
PlayBuffer()
函数将音频数据提供给音频源:void PlayBuffer(const unsigned char* Data, int DataSize) { alGenBuffers( 1, &FBufferID ); alBufferData( FBufferID, AL_FORMAT_MONO16, Data, DataSize, 22050 ); alSourcei( FSourceID, AL_BUFFER, FBufferID ); alSourcePlay( FSourceID ); } };
-
上面的代码使用
sWAVHeader
结构的大小来确定音频数据的偏移量:注意
sWAVHeader
的结构字段对齐应设置为1
。我们的声明与 Android NDK 和 MinGW 的 Clang 和 GCC 编译器兼容。对于 VisualStudio 使用#pragma pack。struct __attribute__((packed,aligned(1))) sWAVHeader { unsigned char RIFF[4]; unsigned int Size; unsigned char WAVE[4]; unsigned char FMT[4]; unsigned int SizeFmt; unsigned short FormatTag; unsigned short Channels; unsigned int SampleRate; unsigned int AvgBytesPerSec; unsigned short nBlockAlign; unsigned short nBitsperSample; unsigned char Reserved[4]; unsigned int DataSize; };
之后我们重用这个结构来加载.wav
文件。
工作原理...
首先,我们声明保存虚拟文件系统和SoundThread
对象的的全局变量:
clPtr<FileSystem> g_FS;
SoundThread g_Sound;
我们创建常规的应用程序模板,并在OnStart()
回调函数中启动一个线程来初始化 OpenAL 库:
void OnStart( const std::string& RootPath )
{
…
g_FS = new FileSystem();
g_FS->Mount( "." );
#if defined(ANDROID)
g_FS->Mount( RootPath );
g_FS->AddAliasMountPoint( RootPath, "assets" );
#endif
g_Sound.Start( iThread::Priority_Normal );
}
另请参阅
-
第二章,移植通用库
-
第四章《组织虚拟文件系统》中的实现可移植的内存映射文件食谱
抽象基本音频组件
在上一个食谱中,我们学习了如何初始化 OpenAL 以及如何播放未压缩的.wav
文件。在这里,我们介绍AudioSource
和AudioThread
类,它们帮助我们管理初始化过程。
准备就绪
查看补充材料中的示例0_AL_On_Android
,以了解 OpenAL 的基本概念。
如何操作…
-
让我们仔细地将 OpenAL 的初始化移动到另一个名为
AudioThread
的线程中:class AudioThread: public iThread { public: AudioThread(): FDevice( NULL ), FContext( NULL ), FInitialized( false ) {} virtual ~AudioThread() {} virtual void Run() {
-
Run()
方法开头的代码执行默认 OpenAL 设备的初始化并创建音频上下文:if ( !LoadAL() ) { return; } FDevice = alcOpenDevice( NULL ); FContext = alcCreateContext( FDevice, NULL ); alcMakeContextCurrent( FContext );
-
我们设置一个标志,告诉其他线程它们是否可以使用我们的音频子系统:
FInitialized = true;
-
然后,我们进入一个无限循环,调用
Env_Sleep()
函数,其源代码如下所述,以避免使用 CPU 的 100%利用率:FPendingExit = false; while ( !IsPendingExit() ) { Env_Sleep( 100 ); }
注意
在此示例中,我们使用了 100 毫秒的固定值将线程置于睡眠模式。在处理音频时,根据缓冲区大小和采样率计算睡眠延迟很有用。例如,一个包含 16 位单声道样本的
65535
字节的缓冲区,在44100
赫兹的采样率下,大约可以播放65535 / (44100 × 16 / 8) ≈ 0.7秒的音频。立体声播放将这个时间减半。 -
最后,我们释放 OpenAL 对象:
alcDestroyContext( FContext ); alcCloseDevice( FDevice ); UnloadAL(); }
-
声明其余部分简单包含了所有必需的字段和初始化标志:
bool FInitialized; private: ALCdevice* FDevice; ALCcontext* FContext; };
-
代码中使用的
Env_Sleep()
函数只是让线程在给定毫秒数内不活跃。它在 Windows 中使用Sleep()
系统调用,在 Android 中使用usleep()
函数实现:void Env_Sleep( int Milliseconds ) { #if defined _WIN32 Sleep( Milliseconds ); #else usleep( static_cast<useconds_t>( Milliseconds ) * 1000 ); #endif }
-
仅播放
.wav
文件还不够,因为我们想要支持不同的音频格式。因此,我们必须将音频播放和文件格式的实际解码分为两个独立的实体。我们准备引入iWaveDataProvider
类,其子类作为我们音频播放类的数据源:class iWaveDataProvider: public iObject { public: iWaveDataProvider(): FChannels( 0 ), FSamplesPerSec( 0 ), FBitsPerSample( 0 ) {}
-
这个类的主要例程允许访问解码的音频数据:
virtual ubyte* GetWaveData() = 0; virtual size_t GetWaveDataSize() const = 0;
-
下面是如何从该提供者获取内部 OpenAL 音频格式标识符的方法:
ALuint GetALFormat() const { if ( FBitsPerSample == 8 ) { return (FChannels == 2) ? AL_FORMAT_STEREO8 : AL_FORMAT_MONO8; } else if ( FBitsPerSample == 16) { return (FChannels == 2) ? AL_FORMAT_STEREO16 : AL_FORMAT_MONO16; } return AL_FORMAT_MONO8; }
-
同时,我们在这里存储有关音频格式的信息:
int FChannels; int FSamplesPerSec; int FBitsPerSample; };
-
如我们所知,必须创建一个音频源以产生声音。这一功能在
AudioSource
类中实现,该类封装了前一个食谱中的 OpenAL 函数调用。这个类使用iWaveDataProvider
实例作为音频数据源:class AudioSource: public iObject { public:
-
构造函数只是创建了一个 OpenAL 源句柄并设置了默认参数:
AudioSource(): FWaveDataProvider( NULL ) { alGenSources( 1, &FSourceID ); alSourcef( FSourceID, AL_GAIN, 1.0 ); alSourcei( FSourceID, AL_LOOPING, 0 ); }
-
析构函数停止播放并执行清理工作:
virtual ~AudioSource() { Stop(); FWaveDataProvider = NULL; alDeleteSources( 1, &FSourceID ); alDeleteBuffers( 1, &FBufferID ); }
-
Play()
方法将 OpenAL 源切换到播放状态:void Play() { if ( IsPlaying() ) { return; } alSourcePlay( FSourceID ); }
-
Stop()
方法将 OpenAL 源切换到停止状态。停止后只能从声音缓冲区的开始处恢复播放:void Stop() { alSourceStop( FSourceID ); }
-
IsPlaying()
方法检查源是否正在播放音频。实现来自之前的食谱:bool IsPlaying() const { int State; alGetSourcei( FSourceID, AL_SOURCE_STATE, &State ); return State == AL_PLAYING; }
-
一个小的
SetVolume()
方法改变源的播放音量。接受的浮点值范围是0.0…1.0
:void SetVolume( float Volume ) { alSourcef( FSourceID, AL_GAIN, Volume ); }
-
主例程,即向音频源提供数据的
BindWaveform()
。这个函数存储了对数据提供者的智能指针,并生成了一个 OpenAL 缓冲区对象:void BindWaveform( clPtr<iWaveDataProvider> Wave ) { FWaveDataProvider = Wave; if ( !Wave ) return; alGenBuffers( 1, &FBufferID ); alBufferData( FBufferID, Wave->GetALFormat(), Wave->GetWaveData(), (int)Wave->GetWaveDataSize(), Wave->FSamplesPerSec ); alSourcei( FSourceID, AL_BUFFER, FBufferID ); }
-
AudioSource
类的私有部分包含对音频数据提供者的引用以及内部 OpenAL 源和缓冲区句柄:private: clPtr<iWaveDataProvider> FWaveDataProvider; ALuint FSourceID; ALuint FBufferID; };
-
为了能够从文件中读取声音,我们在
WavProvider
类中实现了iWaveDataProvider
接口:class WavProvider: public iWaveDataProvider
-
这个类包含的唯一字段是一个指向包含文件数据的
Blob
对象的智能指针:clPtr<Blob> FRawData;
-
一个简单的脉冲编码调制
.wav
文件由开头的sWAVHeader
结构和音频数据组成,可以直接输入到 OpenAL 音频源中。WavProvider
类的构造函数提取有关音频数据的信息:WavProvider( const clPtr<clBlob>& blob ) { FRawData = blob; sWAVHeader H = *(sWAVHeader*)FRawData->GetData(); const unsigned short FORMAT_PCM = 1; FChannels = H.Channels; FSamplesPerSec = H.SampleRate; FBitsPerSample = H.nBitsperSample; }
-
析构函数是空的,因为我们的
Blob
对象被包装成了一个智能指针:virtual ~WavProvider() {}
-
iWaveDataProvider
接口很简单,这里我们只实现两个成员函数。GetWaveData()
返回指向音频数据的指针:virtual ubyte* GetWaveData() { return (ubyte*)FRawData->GetDataConst() + sizeof( sWAVHeader ); }
-
GetWaveDataSize()
方法从总文件大小中减去文件头大小:virtual size_t GetWaveDataSize() const { return FRawData->GetSize() - sizeof( sWAVHeader ); };
现在我们完成了音频播放和解码。
它是如何工作的…
现在我们可以演示如何一起使用所有音频类。像往常一样,我们创建一个空的应用程序模板,可以在1_AL_Abstraction
文件夹中找到。
为了能够使用 OpenAL,我们必须声明一个全局AudioThread
实例:
AudioThread g_Audio;
我们在OnStart()
回调函数中启动这个线程:
g_Audio.Start( iThread::Priority_Normal );
在这个例子中,我们实现了SoundThread
类,其Run()
方法处理所有播放。在这个线程上,我们必须等待g_Audio
初始化完成:
while ( !g_Audio.FInitialized ) {}
现在我们可以创建音频源:
clPtr<AudioSource> Src = new AudioSource();
最后,我们需要创建一个WavProvider
对象,它解码音频文件,将其附加到Src
源,开始播放并等待其完成:
clPtr<Blob> Data = LoadFileAsBlob("test.wav");
Src->BindWaveform( new WavProvider( Data ) );
Src->Play();
while ( Src->IsPlaying() ) {}
音频播放完成后,我们将Src
指针重置为NULL
,并向g_Audio
线程发送终止信号:
Src = NULL;
g_Audio.Exit(true);
为了获取Data
对象,我们必须实现以下函数,它将文件内容读取到内存块中:
clPtr<Blob> LoadFileAsBlob( const std::string& FName )
{
clPtr<iIStream> input = g_FS->CreateReader( FName );
clPtr<Blob> Res = new Blob();
Res->CopyMemoryBlock( input->MapStream(), input->GetSize() );
return Res;
}
我们使用全局初始化的FileSystem
实例,即g_FS
对象。请注意,在 Android OS 上,我们不能使用标准路径,因此采用我们的虚拟文件系统实现。
还有更多…
我们可以实施一些辅助程序,以简化AudioSource
类的使用。第一个有用的例程是源暂停。OpenAL 提供了alSourcePause()
函数,但这还不够,因为我们必须控制所有正在播放的未排队缓冲区。此时,这个未排队并不重要,因为我们只有一个缓冲区,但是当我们开始流式传输声音时,我们必须注意缓冲区队列。以下代码应该添加到AudioSource
类以实现暂停:
void Pause()
{
alSourcePause( FSourceID );
UnqueueAll();
}
void UnqueueAll()
{
int Queued;
alGetSourcei( FSourceID, AL_BUFFERS_QUEUED, &Queued );
if ( Queued > 0 )
alSourceUnqueueBuffers(FSourceID, Queued, &FBufferID);
}
对于无限声音循环,我们可以在AudioSource
类中实现LoopSound()
方法:
void LoopSound( bool Loop )
{
alSourcei( FSourceID, AL_LOOPING, Loop ? 1 : 0);
}
安卓操作系统运行在多种硬件架构上,这可能导致在读取.wav
文件时出现一些额外的困难。如果我们运行的 CPU 具有大端架构,我们就必须交换sWAVHeader
结构字段中的字节。修改后的WavProvider
类的构造函数如下所示:
WavProvider(clPtr<Blob> source)
{
FRawData = source;
sWAVHeader H = *(sWAVHeader*)(FRawData->GetData());
#if __BIG_ENDIAN__
Header.FormatTag = SwapBytes16(Header.FormatTag);
Header.Channels = SwapBytes16(Header.Channels);
Header.SampleRate = SwapBytes32(Header.SampleRate);
Header.DataSize = SwapBytes32(Header.DataSize);
Header.nBlockAlign = SwapBytes16(Header.nBlockAlign);
Header.nBitsperSample = SwapBytes16(Header.nBitsperSample);
大端内存字节顺序要求 16 位值的低字节和高字节互换:
if ( (Header.nBitsperSample == 16) )
{
clPtr<Blob> NewBlob = new clBlob();
NewBlob->CopyBlob( FRawData.GetInternalPtr() );
FRawData = NewBlob;
unsigned short* Ptr =
(unsigned short*)FRawData->GetData();
for ( size_t i = 0 ; i != Header.DataSize / 2; i++ )
{
*Ptr = SwapBytes16(*Ptr);
Ptr++;
}
}
#endif
FChannels = H.Channels;
FSamplesPerSec = H.SampleRate;
FBitsPerSample = H.nBitsperSample;
}
在这里,我们使用 GCC 编译器提供的__BIG_ENDIAN__
预处理器符号来检测大端 CPU。两个SwapBytes()
函数改变无符号字和双字的字节顺序:
unsigned short SwapBytes16( unsigned short Val )
{
return (Val >> 8) | ((Val & 0xFF) << 8);
}
unsigned int SwapBytes32( unsigned int Val )
{
return (( Val & 0xFF ) << 24 ) |
(( Val & 0xFF00 ) << 8 ) |
(( Val & 0xFF0000 ) >> 8 ) |
( Val >> 24);
}
另请参阅
- 解码 Ogg Vorbis 文件
流式声音
我们已经学会了如何播放短音频样本,现在我们准备组织声音流。本食谱解释了如何组织一个缓冲区队列,以允许即时声音生成和流式传输。
准备工作
我们假设读者已经熟悉我们在上一个食谱中描述的AudioSource
和iWaveDataProvider
类。
如何操作...
-
首先,我们用额外的
IsStreaming()
方法丰富iWaveDataProvider
,该方法表示应该以小块的方式从这个提供者读取数据,以及StreamWaveData()
,它实际读取单个块:class iWaveDataProvider: public iObject … virtual bool IsStreaming() const { return false; } virtual int StreamWaveData( int Size ) { return 0; } … };
-
接下来,我们编写一个派生类,其中包含一个用于解码或生成的声音数据的中间缓冲区。它没有实现
StreamWaveData()
,但实现了GetWaveData()
和GetWaveDataSize()
方法:class StreamingWaveDataProvider: public iWaveDataProvider { public: virtual bool IsStreaming() const { return true; } virtual ubyte* GetWaveData() { return (ubyte*)&FBuffer[0]; } virtual size_t GetWaveDataSize() const { return FBufferUsed; } std::vector<char> FBuffer; int FBufferUsed; };
-
FBufferUsed
字段保存了FBuffer
向量中使用的字节数。现在我们修改AudioSource
类以支持我们的新流式数据提供者。我们不希望在播放过程中出现裂缝或中断,因此我们使用缓冲区队列代替在单块声音播放中使用的单个缓冲区。为此,我们首先声明一个缓冲区计数器和缓冲区 ID 数组:class AudioSource: public iObject { private: unsigned int FSourceID; int FBuffersCount; unsigned int FBufferID[2];
-
我们将
LoopSound()
、Stop()
、Pause()
、IsPlaying()
和SetVolume()
成员函数,构造函数和析构函数的实现保持不变。现在BindWaveform()
方法在关联的波形数据提供者支持流式传输时生成缓冲区:void BindWaveform( clPtr<iWaveDataProvider> Wave ) { FWaveDataProvider = Wave; if ( !Wave ) return; if ( Wave->IsStreaming() ) { FBuffersCount = 2; alGenBuffers( FBuffersCount, &FBufferID[0] ); } else { FBuffersCount = 1; alGenBuffers( FBuffersCount, &FBufferID[0] ); alBufferData( FBufferID[0], Wave->GetALFormat(), Wave->GetWaveData(), (int)Wave->GetWaveDataSize(), Wave->FSamplesPerSec ); alSourcei( FSourceID, AL_BUFFER, FBufferID[0] ); } }
-
Play()
方法调用alSourcePlay()
函数,并在流式传输模式下将缓冲区添加到队列中:void Play() { if ( IsPlaying() ) { return; } if ( !FWaveDataProvider ) { return; } int State; alGetSourcei( FSourceID, AL_SOURCE_STATE, &State ); if ( State != AL_PAUSED && FWaveDataProvider->IsStreaming() ) { UnqueueAll();
-
将两个音频缓冲区填充好并将它们提交给 OpenAL API:
StreamBuffer( FBufferID[0], BUFFER_SIZE ); StreamBuffer( FBufferID[1], BUFFER_SIZE ); alSourceQueueBuffers(FSourceID, 2, &FBufferID[0]); } alSourcePlay( FSourceID ); }
-
既然我们使用了不止一个缓冲区,我们将在
UnqueueAll()
方法中将FBufferID
更改为FBufferID[0]
:void UnqueueAll() { int Queued; alGetSourcei(FSourceID, AL_BUFFERS_QUEUED, &Queued); if ( Queued > 0 ) alSourceUnqueueBuffers(FSourceID, Queued, &FBufferID[0]); }
-
最后,由于流式传输是一个持续的过程,而不是一次性的操作,我们提供了
Update()
方法,它从iWaveDataProvider
获取适当量的数据:void Update( float DeltaSeconds ) { if ( !FWaveDataProvider ) { return; } if ( !IsPlaying() ) { return; } if ( FWaveDataProvider->IsStreaming() ) { int Processed; alGetSourcei( FSourceID, AL_BUFFERS_PROCESSED, &Processed ); while ( Processed-- ) { unsigned int BufID; alSourceUnqueueBuffers(FSourceID,1,&BufID); StreamBuffer( BufID, BUFFER_SIZE ); alSourceQueueBuffers(FSourceID, 1, &BufID); } } }
-
在
Update()
方法中,我们使用了StreamBuffer()
成员函数,它负责用提供者解码或生成的数据填充缓冲区:int StreamBuffer( unsigned int BufferID, int Size ) { int ActualSize = FWaveDataProvider->StreamWaveData(Size); ubyte* Data = FWaveDataProvider->GetWaveData(); int Sz = (int)FWaveDataProvider->GetWaveDataSize(); alBufferData( BufferID, FWaveDataProvider->GetALFormat(), Data, Sz, FWaveDataProvider->FSamplesPerSec ); return ActualSize; }
-
BUFFER_SIZE
常数被设置为足够大,以容纳几秒钟的流式数据:const int BUFFER_SIZE = 352800;
注意
值
352800
的推导如下:2 通道 × 44,100 每秒采样数 × 每个样本 2 字节 × 2 秒 = 352,800 字节。
工作原理…
本食谱中的代码没有实现StreamWaveData()
方法。为了从扬声器中听到声音,我们编写了ToneGenerator
类,它生成纯正弦波作为输出数据。这个类是从StreamingWaveDataProvider
派生而来的:
class ToneGenerator : public StreamingWaveDataProvider
{
首先声明信号参数和内部样本计数器:
int FSignalFreq;
float FFrequency;
float FAmplitude;
private:
int LastOffset;
构造函数设置声音数据参数并预先分配缓冲区空间:
public:
ToneGenerator()
{
FBufferUsed = 100000;
FBuffer.resize( 100000 );
FChannels = 2;
FSamplesPerSec = 4100;
FBitsPerSample = 16;
FAmplitude = 350.0f;
FFrequency = 440.0f;
}
virtual ~ToneGenerator() {}
这个类的主例程计算正弦函数,跟踪当前样本索引,以使声音缓冲队列包含所有值:
virtual int StreamWaveData( int Size )
{
if ( Size > static_cast<int>( FBuffer.size() ) )
{
FBuffer.resize( Size );
LastOffset = 0;
}
for ( int i = 0 ; i < Size / 4 ; i++ )
{
正弦函数的参数t
是从局部索引i
和名为LastOffset
的相位值计算得出的:
float t = ( 2.0f * 3.141592654f *
FFrequency * ( i + LastOffset ) ) /
(float) FSamplesPerSec;
float val = FAmplitude * std::sin( t );
以下几行代码将单个浮点数值转换成有符号字。这种转换是必要的,因为数字音频硬件只能处理整数数据:
short V = static_cast<short>( val );
FBuffer[i * 4 + 0] = V & 0xFF;
FBuffer[i * 4 + 1] = V >> 8;
FBuffer[i * 4 + 2] = V & 0xFF;
FBuffer[i * 4 + 3] = V >> 8;
}
接下来,我们在保持生成的样本计数器在0…FSignalFreq-1
范围内时递增它:
LastOffset += Size / 2;
LastOffset %= FSamplesPerSec;
最后,返回生成的字节数:
FBufferUsed = Size;
return FBufferUsed;
}
};
现在,我们可以使用AudioSource
类来流式传输声音。一旦创建了音频源,我们就附加一个新的流式提供者,它生成 440 Hz 的正弦波形:
class SoundThread: public iThread
{
virtual void Run()
{
while ( !g_Audio.Finitialized ) {}
clPtr<AudioSource> Src = new AudioSource();
Src->BindWaveform( new ToneGenerator() );
Src->Play();
FPendingExit = false;
double Seconds = Env_GetSeconds();
在无限循环中,我们不断更新源,强制它生成声音数据:
While ( !IsPendingExit() )
{
float DeltaSeconds =
(float)( Env_GetSeconds() - Seconds );
Src->Update( DeltaSeconds );
Seconds = Env_GetSeconds();
}
}
}
还有更多…
容易注意到在ToneGenerator::StreamWaveData()
成员函数中,我们可以使用任何公式,不仅仅是正弦函数。我们鼓励读者进行实验,创建某种软件合成器。
解码 Ogg Vorbis 文件
Ogg Vorbis 是一种广泛使用的、免费的、开放的、无专利的音频压缩格式。它可以与其他用于存储和播放数字音乐的格式相媲美,如 MP3、VQF 和 AAC。
准备就绪
读者应该熟悉前一个食谱中的声音流传输技术。关于.ogg
容器文件格式和 Vorbis 音频压缩算法的详细信息可以在xiph.org
找到。
如何操作…
-
我们向
iWaveDataProvider
接口添加了IsEOF()
方法。这用于通知AudioSource
声音何时结束:virtual bool IsEOF() const { return true; }
-
我们添加的另一个方法是
Seek()
,它倒带音频流:virtual void Seek( float Time ) {}
-
在
DecodingProvider
类中,我们实现了StreamWaveData()
成员函数,它使用ReadFromFile()
方法从源内存块中读取解码的音频数据:class DecodingProvider: public StreamingWaveDataProvider { clPtr<Blob> FRawData; public: bool FEof; virtual bool IsEOF() const { return FEof; }
-
FLoop
标志告诉解码器,如果遇到流末尾,则倒回并从开始处重新播放:bool FLoop; public: DecodingProvider( const clPtr<Blob>& blob ) { FRawData = blob; FEof = false; }
-
主要的流处理程序尝试从源内存块中读取更多数据:
virtual int StreamWaveData( int Size ) {
-
我们用零填充缓冲区的未使用部分以避免噪音:
int OldSize = (int)FBuffer.size(); if ( Size > OldSize ) { FBuffer.resize( Size ); for ( int i = 0 ; i < OldSize - Size ; i++ ) FBuffer[OldSize + i] = 0; }
-
在文件末尾,我们将解码数据的大小返回为零:
if ( FEof ) { return 0; }
-
接下来,我们尝试从源读取,直到收集到
Size
个字节:int BytesRead = 0; while ( BytesRead < Size ) { int Ret = ReadFromFile(Size);
-
如果我们有数据,增加计数器:
if ( Ret > 0 ) { BytesRead += Ret; }
-
如果字节数为零,我们已经到达文件的末尾:
else if (Ret == 0) { FEof = true;
-
FLoop
标志告诉我们需要将流倒回到开始处:if ( FLoop ) { Seek(0); FEof = false; continue; } break; } else
-
否则,我们在流中有一个错误:
{ Seek( 0 ); FEof = true; break; } }
-
当前缓冲的字节数现在是文件中读取的字节数:
return ( FBufferUsed = BytesRead ); }
-
ReadFromFile()
函数在这里是纯虚的,实现都在派生类中:protected: virtual int ReadFromFile(int Size) = 0; };
-
在第二章《移植通用库》中,我们编译了 Ogg 和 Vorbis 静态库。我们现在在
OggProvider
类中使用它们,该类实现了实际音频数据的解码:class OggProvider: public DecodingProvider {
-
解码器的状态存在于三个变量中:
OggVorbis_File FVorbisFile; ogg_int64_t FOGGRawPosition; int FOGGCurrentSection;
-
构造函数初始化 Ogg 和 Vorbis 库。
Callbacks
结构包含指向函数的指针,这允许 OGG 库使用我们的虚拟文件系统流从我们的内存块中读取数据:public: OggProvider( const clPtr<Blob>& Blob ): DecodingProvider(Blob) { FOGGRawPosition = 0;
-
填充
Callbacks
结构并初始化文件阅读器:ov_callbacks Callbacks; Callbacks.read_func = OGG_ReadFunc; Callbacks.seek_func = OGG_SeekFunc; Callbacks.close_func = OGG_CloseFunc; Callbacks.tell_func = OGG_TellFunc; OGG_ov_open_callbacks( this, &FVorbisFile, NULL, -1, Callbacks );
-
声明
vorbis_info
结构以读取音频流的持续时间。存储关于流的信息:vorbis_info* VorbisInfo; VorbisInfo = OGG_ov_info ( &FVorbisFile, -1 ); FChannels = VorbisInfo->channels; FSamplesPerSec = VorbisInfo->rate;
-
FBitsPerSample
结构被设置为 16 位,然后我们告诉解码器以 16 位信号输出音频数据:FBitsPerSample = 16; }
-
在析构函数中,
FVorbisFile
被清除:virtual ~OggProvider() { OGG_ov_clear( &FVorbisFile ); }
-
ReadFromFile()
函数使用 OGG 库进行流解码:virtual int ReadFromFile(int Size, int BytesRead) { return (int)OGG_ov_read( &FVorbisFile, &FBuffer[0] + BytesRead, Size - BytesRead,
-
在这里,我们假设我们正在小端 CPU 上运行,例如 Intel Atom、Intel Core,或其他通常在移动 Android 设备中遇到的 ARM 处理器(
en.wikipedia.org/wiki/Endianness
)。如果不是这种情况,例如处理器是 PowerPC 或 MIPS 在大端模式下,你应该向OGG_ov_read()
函数提供一个1
作为参数:0, // 0 for LITTLE_ENDIAN, 1 for BIG_ENDIAN FBitsPerSample >> 3, 1, &FOGGCurrentSection ); }
-
Seek()
成员函数将流倒回到指定的时间:virtual void Seek( float Time ) { FEof = false; OGG_ov_time_seek( &FVorbisFile, Time ); }
-
在类的定义末尾,包含了
OGG_Callbacks.h
文件,其中实现了静态回调函数:private: #include "OGG_Callbacks.h" };
-
OGG_Callbacks.h
文件中的函数实现了一个类似FILE*
的接口,OGG 库使用它来读取我们的内存块。我们在所有这些函数中将OggProvider
的实例作为void* DataSource
参数传递。 -
OGG_ReadFunc()
函数读取指定数量的字节并检查数据的末尾:size_t OGG_ReadFunc( void* Ptr, size_t Size, size_t NMemB, void* DataSource ) { OggProvider* OGG = (OggProvider*)DataSource; size_t DataSize = OGG->FRawData->GetSize(); ogg_int64_t BytesRead = DataSize - OGG- >FOGGRawPosition; ogg_int64_t BytesSize = Size * NMemB; if ( BytesSize < BytesRead ) { BytesRead = BytesSize; } memcpy( Ptr, (ubyte*)OGG->FRawData->GetDataConst() + OGG->FOGGRawPosition, (size_t)BytesRead ); OGG->FOGGRawPosition += BytesRead; return (size_t)BytesRead; }
-
OGG_SeekFunc()
函数将当前读取位置设置为Offset
的值:int OGG_SeekFunc( void* DataSource, ogg_int64_t Offset, int Whence ) { OggProvider* OGG = (OggProvider*)DataSource; size_t DataSize = OGG->FRawData->GetSize(); if ( Whence == SEEK_SET ) { OGG->FOGGRawPosition = Offset; } else if ( Whence == SEEK_CUR ) { OGG->FOGGRawPosition += Offset; } else if ( Whence == SEEK_END ) { OGG->FOGGRawPosition = DataSize + Offset; }
-
防止位置超过流结尾:
if ( OGG->FOGGRawPosition > (ogg_int64_t)DataSize ) { OGG->FOGGRawPosition = (ogg_int64_t)DataSize; } return static_cast<int>( OGG->FOGGRawPosition ); }
-
由于我们使用内存块作为数据源,
OGG_CloseFunc()
函数立即返回零,因为我们不需要关闭任何句柄:int OGG_CloseFunc( void* DataSource ) { return 0; }
-
OGG_TellFunc()
函数返回当前的读取位置:long OGG_TellFunc( void* DataSource ) { return (int) (((OggProvider*)DataSource)->FOGGRawPosition); }
工作原理…
我们像之前的食谱一样初始化 OpenAL,并将OggProvider
绑定到AudioSource
实例的数据源:
clPtr<AudioSource> Src = new AudioSource();
clPtr<Data> = LoadFileAsBlob( "test.ogg" );
Src->BindWaveform( new OggProvider(Data) );
Src->Play();
FPendingExit = false;
double Seconds = Env_GetSeconds();
在循环中更新音频源,就像我们对ToneGenerator
所做的那样:
While ( !IsPendingExit() )
{
float DeltaSeconds =
(float)(Env_GetSeconds() - Seconds );
Src->Update(DeltaSeconds);
Seconds = Env_GetSeconds();
}
LoadFileAsBlob()
函数与我们用来加载.wav
文件的函数相同。
使用 ModPlug 解码跟踪器音乐
与桌面计算机相比,移动设备在资源上总是受限的。这些限制既包括计算能力,也包括可用的存储空间。即使是在适中的比特率下,高质量的 MPEG-1 Layer 3 或 Ogg Vorbis 音频文件也会占用大量空间。例如,在一个 20 Mb 的游戏中,两个各占 5 Mb 大小的音轨是不可接受的。然而,质量和压缩之间有一个很好的折中方案。一种起源于八十年代的技术,称为跟踪器音乐——有时也被称为芯片音乐或 8 位音乐(en.wikipedia.org/wiki/Music_tracker
)。跟踪器音乐格式不使用脉冲编码调制来存储整个音轨。相反,它们使用音符
和效果,这些音符和效果被应用到样本
并在多个通道中播放。样本
是乐器的小型 PCM 编码声音。音符
对应于样本的播放速度。我们使用libmodplug库来解码最流行的跟踪器音乐文件格式,如.it
、.xm
和.mod
。
准备就绪
在modplug-xmms.sourceforge.net
查看 libmodplug 的最新版本。
如何操作...
-
ModPlug 库允许我们实现另一个从
DecodingProvider
派生的类,称为ModPlugProvider
。该库支持直接解码内存块,因此我们不需要实现任何 I/O 回调:class ModPlugProvider: public DecodingProvider {
-
作为状态,这个类包含了
ModPlugFile
结构:private: ModPlugFile* FModFile;
-
唯一的构造函数初始化了
ModPlugFile
字段:public: explicit ModPlugProvider( const clPtr<Blob>& Blob ) : DecodingProvider(Blob) { FChannels = 2; FSamplesPerSec = 44100; FBitsPerSample = 16; FModFile = ModPlug_Load_P( ( const void* )FRawData->GetDataConst(), ( int )FRawData->GetSize() ); }
-
析构函数卸载文件:
virtual ~ModPlugProvider() { ModPlug_Unload_P( FModFile ); }
-
ReadFromFile()
方法调用 ModPlug 的读取函数:virtual int ReadFromFile(int Size, int BytesRead) { return ModPlug_Read_P( FModFile, &FBuffer[0] + BytesRead, Size - BytesRead ); }
-
要重置源流,我们使用
ModPlug_Seek()
成员函数:virtual void Seek( float Time ) { FEof = false; ModPlug_Seek_P( FModFile, ( int )( Time * 1000.0f ) ); } };
工作原理...
没有专用的样本用于模块文件解码。为了更好地理解,我们建议修改3_AL_PlayingOGG
源代码。唯一需要的修改是将OggProvider
替换为ModPlugProvider
。在测试中,你可以在3_AL_PlayingOGG
文件夹中找到test.it
文件。
另请参阅
- 解码 Ogg Vorbis 文件
第六章:统一 OpenGL ES 3 和 OpenGL 3
在本章中,我们将涵盖:
-
统一 OpenGL 3 核心配置文件和 OpenGL ES 2
-
在 Windows 上初始化 OpenGL 3 核心配置文件
-
在 Android 上初始化 OpenGL ES 2
-
统一 GLSL 3 和 GLSL ES 2 着色器
-
操作几何图形
-
统一顶点数组
-
为纹理创建一个包装器
-
创建一个用于即时渲染的画布
引言
毫无疑问,任何游戏都需要渲染一些图形。在本章中,我们将学习如何为你的游戏创建一个可移植的图形渲染子系统。章节标题为《统一 OpenGL ES 3 和 OpenGL 3》;然而,在本书中我们处理的是可移植开发,因此我们从 OpenGL 3 桌面 API 开始我们的教程。这有两个目的。首先,OpenGL 3 几乎是 OpenGL ES 3 的超集。这将允许我们轻松地在两个 OpenGL API 版本之间移植应用程序。其次,我们可以创建一个简单但非常有效的包装器,来抽象游戏代码中的两个 API,这样我们就能在桌面 PC 上开发我们的游戏。
注意
OpenGL ES 3 的支持在 Android 4.3 和 Android NDK r9 中引入。然而,本书中的所有示例都向下兼容此移动 API 的前一个版本,即 OpenGL ES 2。
OpenGL 本身是一个庞大的主题,值得专门用一本书来讲述。我们建议从《OpenGL 编程指南》,Pearson 出版物(红书)开始学习。
统一 OpenGL 3 核心配置文件和 OpenGL ES 2
让我们在 OpenGL 3 和 OpenGL ES 2 之上实现一个薄的抽象层,使我们的高级代码不知道应用程序运行的具体 GL 版本。这意味着我们的游戏代码可以完全不知道它是在移动版还是桌面版的 OpenGL 上运行。请看以下图表:
我们将在本章中实现的部分位于高级 API 矩形内。
准备就绪
在第四章,组织虚拟文件系统中,我们创建了一个示例 3_AsyncTexture
,我们学习了如何在 Android 上使用 Java 初始化 OpenGL ES 2。现在我们使用该示例中的 GLView.java
在 Android 上初始化一个渲染上下文。不涉及来自 Android NDK 的 EGL,因此我们的示例将在 Android 2.1 及更高版本上运行。
如何操作…
-
在上一个教程中,我们提到了
sLGLAPI
结构。它包含在启动时动态加载的 OpenGL 函数的指针。声明可以在LGLAPI.h
中找到,它从以下代码开始:struct sLGLAPI { sLGLAPI() { memset( this, 0, sizeof( *this ) ); }; …Win32 defines skipped here… PFNGLACTIVETEXTUREPROC glActiveTexture; PFNGLATTACHSHADERPROC glAttachShader; PFNGLBINDATTRIBLOCATIONPROC glBindAttribLocation; …
-
定义一个变量来保存指向此结构的指针:
sLGLAPI* LGL3;
-
这意味着我们必须通过包含在
LGL3
中的指针调用所有 OpenGL 函数。例如,以下是来自2_OpenGLES2
示例的OnDrawFrame()
的代码:void OnDrawFrame() { LGL3->glClearColor( 1.0, 0.0, 0.0, 0.0 ); LGL3->glClear( GL_COLOR_BUFFER_BIT ); }
比简单的
glClear(GL_COLOR_BUFFER_BIT)
调用复杂一点,那么为什么我们需要它呢?根据你的应用程序在不同平台上链接到 OpenGL 的方式,glClear
类的实体可以以两种方式表示。如果你的应用程序是动态链接到 OpenGL 的,那么像glClear
这样的全局符号由持有从.DLL/.so
库中检索的函数指针的全局变量表示。你的应用程序也可能静态链接到某些 OpenGL 包装库,正如在 Android 上使用-lGLESv2
和-lGLESv3
开关在LOCAL_LDLIBS
中所做的那样。在这种情况下,glClear()
将是一个函数,而不是一个变量,你不能更改它包含的代码。此外,如果我们查看某些 OpenGL 3 函数,例如glClearDepth(double Depth)
,却发现 OpenGL ES 2 没有直接等效的函数,事情就会变得更加复杂。这就是为什么我们需要一个可以随意更改的 OpenGL 函数指针集合。 -
在 Android 上,我们定义了一个 thunk 函数:
void Emulate_glClearDepth( double Depth ) { glClearDepthf( static_cast<float>( Depth ) ); }
-
这个函数模拟了 OpenGL 3 的
glClearDepth()
调用,使用了 OpenGL ES 3 的glClearDepthf()
调用。现在事情又变得简单了。有些 GL3 函数不能在 GLES3 中轻易模拟。现在我们可以轻松地为它们实现空的存根,例如:void Emulate_glPolygonMode( GLenum, GLenum ) { // not supported }
在此情况下,未实现的功能将禁用一些渲染能力;但应用程序将正常运行,在 GLES2 上优雅降级。一些更复杂的内容,例如使用glBindFragDataLocation()
的多重渲染目标,仍然需要我们为 OpenGL 3 和 OpenGL ES 2 选择不同的着色器程序和代码路径。然而,现在这是可行的。
工作原理…
sLGLAPI
绑定代码在GetAPI()
函数中实现。之前描述的 Windows 版本是简单的.DLL
加载代码。Android 版本甚至更简单。由于我们的应用程序是静态链接到 OpenGL ES 2 库的,我们只需将函数指针分配给sLGLAPI
的字段,除了在 OpenGL ES 2 中不存在的调用:
void GetAPI( sLGLAPI* API ) const
{
API->glActiveTexture = &glActiveTexture;
API->glAttachShader = &glAttachShader;
API->glBindAttribLocation = &glBindAttribLocation;
…
相反,我们使用之前描述的存根:
API->glClearDepth = &Emulate_glClearDepth;
API->glBindFragDataLocation = &Emulate_glBindFragDataLocation;
…
现在 OpenGL 的使用完全是透明的,我们的应用程序完全不知道实际使用的是哪种 OpenGL 版本。看看OpenGL3.cpp
文件:
#include <stdlib.h>
#include "LGL.h"
sLGLAPI* LGL3 = NULL;
void OnDrawFrame()
{
LGL3->glClearColor( 1.0, 0.0, 0.0, 0.0 );
LGL3->glClear( GL_COLOR_BUFFER_BIT );
}
这段代码在 Windows 和 Android 上运行完全相同。
还有更多…
使用以下命令可以构建2_OpenGLES2
示例的 Android 版本:
>ndk-build
>ant copy-common-media debug
运行应用程序将整个屏幕涂成红色,并将表面大小输出到系统日志中:
W/GLView ( 3581): creating OpenGL ES 2.0 context
I/App13 ( 3581): SurfaceSize: 1196 x 720
在 OpenGL 3 Core Profile、OpenGL ES 2 和 OpenGL ES 3 之间还存在其他无法通过模仿所有 API 函数调用来抽象的差异。这包括 GLSL 着色器的不同语法,以及在 OpenGL 3.2 Core Profile 中必须使用的顶点数组对象(VAO),这在 OpenGL ES 2 中是不存在的。
另请参阅
-
统一 GLSL 3 和 GLSL ES 2 着色器
-
操作几何图形
-
统一顶点数组
-
创建纹理的包装器
在 Windows 上初始化 OpenGL 3 核心配置文件
OpenGL 3.0 引入了功能弃用的概念。某些功能可能被标记为弃用,并在后续版本中从规范中移除。例如,通过 glBegin
()/glEnd
()
的立即模式渲染在 OpenGL 标准版本 3.0 中被标记为弃用,并在版本 3.1 中移除。然而,许多 OpenGL 实现保留了弃用的功能。例如,它们希望为使用现代 OpenGL 版本的用户提供一种访问旧 API 功能的方法。
从 OpenGL 版本 3.2 开始,引入了一种新机制,允许用户创建特定版本的渲染上下文。每个版本都允许向后兼容或核心配置文件上下文。向后兼容的上下文允许使用所有标记为弃用的功能。核心配置文件上下文移除了弃用的功能,使 API 更干净。此外,OpenGL 3 核心配置文件比之前的 OpenGL 版本更接近移动 OpenGL ES 2。由于本书的目标是提供一种在桌面上开发移动应用程序的方法,这种功能集的相似性将非常有用。让我们找出如何在 Windows 上手动创建核心配置文件上下文。
注意
对于使用 Unix 或 Mac 桌面计算机的读者,我们建议使用 GLFW 库来创建 OpenGL 上下文,该库可在www.glfw.org
获取。
准备就绪
有关核心和兼容性上下文的更多信息可以在官方 OpenGL 页面找到,链接为www.opengl.org/wiki/Core_And_Compatibility_in_Contexts
。
如何操作…
有一个名为 WGL_ARB_create_context
的 OpenGL 扩展,可以在 Windows 上创建特定版本的 OpenGL 上下文,相关信息可在www.opengl.org/registry/specs/ARB/wgl_create_context.txt
找到。
技巧在于,我们只能从现有的有效 OpenGL 上下文中获取到 wglCreateContextAttribsARB()
函数的指针,该函数可以创建核心配置文件上下文。这意味着我们必须初始化 OpenGL 两次。首先,我们使用 glCreateContext()
创建一个临时的兼容性上下文,并获取到 wglCreateContextAttribsARB()
扩展函数的指针。然后,我们继续使用扩展函数创建指定版本和所需标志的 OpenGL 上下文。以下是我们用于创建 OpenGL 渲染上下文的代码:
注意
sLGLAPI
结构包含我们使用的所有 OpenGL 函数的指针。阅读之前的菜谱 统一 OpenGL 3 核心配置文件和 OpenGL ES 2 以了解实现细节。
HGLRC CreateContext( sLGLAPI* LGL3, HDC DeviceContext,int VersionMajor, int VersionMinor )
{
HGLRC RenderContext = 0;
第一次调用此函数时,它会进入else
块并创建一个向后兼容的 OpenGL 上下文。当你获取到有效的wglCreateContextAttribsARB()
函数指针时,将其保存在sLGLAPI
结构中,并再次调用CreateContext()
。这次第一个if
块将接管控制:
if ( LGL3->wglCreateContextAttribsARB )
{
const int Attribs[] =
{
WGL_CONTEXT_MAJOR_VERSION_ARB, VersionMajor,
WGL_CONTEXT_MINOR_VERSION_ARB, VersionMinor,
WGL_CONTEXT_LAYER_PLANE_ARB, 0,
WGL_CONTEXT_FLAGS_ARB,
WGL_CONTEXT_FORWARD_COMPATIBLE_BIT_ARB,
WGL_CONTEXT_PROFILE_MASK_ARB,
WGL_CONTEXT_CORE_PROFILE_BIT_ARB,
0 // zero marks the end of values
};
RenderContext = LGL3->wglCreateContextAttribsARB(DeviceContext, 0, Attribs );
}
else
{
-
lglCreateContext()
调用只是针对特定操作系统 API 调用的封装,在本例中是wglCreateContext()
:RenderContext = LGL3->lglCreateContext(DeviceContext ); } return RenderContext; }
-
这个函数被包装在
CreateContextFull()
函数中,它选择适当的像素格式并使上下文成为当前:HGLRC CreateContextFull( sLGLAPI* LGL3, HDC DeviceContext,int BitsPerPixel, int ZBufferBits, int StencilBits,int Multisample, int VersionMajor, int VersionMinor ) { bool FormatSet = ChooseAndSetPixelFormat( LGL3,DeviceContext,BitsPerPixel, ZBufferBits, StencilBits, Multisample ); if ( !FormatSet ) return 0; HGLRC RenderContext = CreateContext( LGL3,DeviceContext, VersionMajor, VersionMinor ); if ( !RenderContext ) return 0; if ( !MakeCurrent( LGL3, DeviceContext, RenderContext ) ) { return 0; } Reload( LGL3 ); return RenderContext; }
它返回创建的 OpenGL 渲染上下文,在 Windows 上是
HGLRC
,并更新LGL3
结构中的指针以对应创建的上下文。注意
之前描述的函数有许多副作用,一些函数式程序员认为它不一致。另一种方法是返回一个新的
HGLRC
以及新的LGL3
(或者作为新LGL3
的一部分),这样你可以在稍后自行决定使其成为当前上下文,并且仍然可以访问旧的上下文。我们将这个想法留给读者作为一个练习。之前提到的
Reload()
函数重新加载了sLGLAPI
结构中的 OpenGL 函数指针。这种间接调用很重要,因为我们需要模拟 OpenGL 3 函数在 OpenGL ES 2 上的行为。像素格式选择还使用了另一个 OpenGL 扩展:
WGL_ARB_pixel_format
,可在www.opengl.org/registry/specs/ARB/wgl_pixel_format.txt
找到。 -
这意味着我们必须选择并设置像素格式两次。代码如下:
bool ChooseAndSetPixelFormat( sLGLAPI* LGL3, HDCDeviceContext,int BitsPerPixel, int ZBufferBits, int StencilBits,int Multisample ) { PIXELFORMATDESCRIPTOR PFD; memset( &PFD, 0, sizeof( PFD ) ); PFD.nSize = sizeof( PIXELFORMATDESCRIPTOR ); PFD.nVersion = 1; PFD.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; PFD.iPixelType = PFD_TYPE_RGBA; PFD.cColorBits = static_cast<BYTE>(BitsPerPixel & 0xFF); PFD.cDepthBits = static_cast<BYTE>(ZBufferBits & 0xFF); PFD.cStencilBits = static_cast<BYTE>(StencilBits & 0xFF); PFD.iLayerType = PFD_MAIN_PLANE; GLint PixelFormat = 0;
-
如果有效的指针可用,请尝试使用该扩展:
if ( LGL3->wglChoosePixelFormatARB ) { const int Attribs[] = { WGL_DRAW_TO_WINDOW_ARB, GL_TRUE,WGL_SUPPORT_OPENGL_ARB, GL_TRUE,WGL_ACCELERATION_ARB, WGL_FULL_ACCELERATION_ARB,WGL_DOUBLE_BUFFER_ARB , GL_TRUE,WGL_PIXEL_TYPE_ARB , WGL_TYPE_RGBA_ARB,WGL_COLOR_BITS_ARB , BitsPerPixel,WGL_DEPTH_BITS_ARB , ZBufferBits,WGL_STENCIL_BITS_ARB , StencilBits,WGL_SAMPLE_BUFFERS_ARB, GL_TRUE,WGL_SAMPLES_ARB , Multisample,0 // zero marks the end of values }; GLuint Count = 0; LGL3->wglChoosePixelFormatARB( DeviceContext,Attribs, NULL, 1, &PixelFormat, &Count ); if ( !PixelFormat ) { PixelFormat = ::ChoosePixelFormat( DeviceContext, &PFD ); } return ::SetPixelFormat( DeviceContext,PixelFormat, NULL ); }
-
或者,退回到 WinAPI 提供的像素格式选择函数:
if ( !PixelFormat ) { PixelFormat = ::ChoosePixelFormat(DeviceContext, &PFD); } return ::SetPixelFormat( DeviceContext, PixelFormat, &PFD ); }
它的工作原理是…
Reload()
函数加载opengl32.dll
并获取某些 WGL 函数的指针(en.wikipedia.org/wiki/WGL_(API)
):
void LGL::clGLExtRetriever::Reload( sLGLAPI* LGL3 )
{
if ( !FLibHandle ) FLibHandle =
(void*)::LoadLibrary( "opengl32.dll" );
LGL3->lglGetProcAddress = ( PFNwglGetProcAddress )
::GetProcAddress( (HMODULE)FLibHandle, "wglGetProcAddress" );
LGL3->lglCreateContext = ( PFNwglCreateContext )
::GetProcAddress( (HMODULE)FLibHandle, "wglCreateContext" );
LGL3->lglGetCurrentContext = ( PFNwglGetCurrentContext )
::GetProcAddress( (HMODULE)FLibHandle,"wglGetCurrentContext");
LGL3->lglMakeCurrent = ( PFNwglMakeCurrent )
::GetProcAddress( (HMODULE)FLibHandle, "wglMakeCurrent" );
LGL3->lglDeleteContext = ( PFNwglDeleteContext )
::GetProcAddress( (HMODULE)FLibHandle, "wglDeleteContext" );
GetAPI( LGL3 );
}
GetAPI()
函数要大得多,但仍然很简单。以下是一些代码行,以给你一个大概的想法:
void LGL::clGLExtRetriever::GetAPI( sLGLAPI* API ) const
{
API->glActiveTexture = ( PFNGLACTIVETEXTUREPROC )GetGLProc( API, "glActiveTexture" );
API->glAttachShader = ( PFNGLATTACHSHADERPROC )GetGLProc( API, "glAttachShader" );
…
完整的源代码在1_OpenGL3
文件夹中。你可以使用make
来构建它:
>make all
本示例打开一个背景为红色的窗口,并打印出类似于以下内容的行:
Using glCreateContext()
Using wglCreateContextAttribsARB()
OpenGL version: 3.2.0
OpenGL renderer: GeForce GTX 560/PCIe/SSE2
OpenGL vendor: NVIDIA Corporation
OpenGL 上下文版本与glCreateContextAttribsARB()
调用中指定的版本相匹配。
还有更多…
在 WinAPI 中不允许多次设置窗口的像素格式。因此,我们使用一个临时的不可见窗口来创建第一个渲染上下文并获取扩展。查看1_OpenGL3
示例中的OpenGL3.cpp
文件,了解进一步的实现细节。
另请参阅
- 统一 OpenGL 3 核心配置文件和 OpenGL ES 3
在 Android 上初始化 OpenGL ES 2。
与 Windows 相比,Android 上的 OpenGL 初始化非常直接。在 Android NDK 中创建 OpenGL 渲染上下文有两种方法:直接使用来自 NDK 的 EGL API([en.wikipedia.org/wiki/EGL_(API)
](http://en.wikipedia.org/wiki/EGL_(API))),或者基于android.opengl.GLSurfaceView
创建一个包装 Java 类。我们将选择第二种方法。
准备就绪
熟悉GLSurfaceView
类的接口,请访问developer.android.com/reference/android/opengl/GLSurfaceView.html
。
如何操作…
-
我们以下列方式扩展了
GLSurfaceView
类:public class GLView extends GLSurfaceView { …
-
init()
方法为帧缓冲区选择RGB_888
像素格式:private void init( int depth, int stencil ) { this.getHolder().setFormat( PixelFormat.RGB_888 ); setEGLContextFactory( new ContextFactory() ); setEGLConfigChooser(new ConfigChooser( 8, 8, 8, 0, depth, stencil ) ); setRenderer( new Renderer() ); }
-
这个内部类执行 EGL 调用以创建 OpenGL 渲染上下文:
private static class ContextFactory implementsGLSurfaceView.EGLContextFactory { private static int EGL_CONTEXT_CLIENT_VERSION = 0x3098; public EGLContext createContext( EGL10 egl,EGLDisplay display, EGLConfig eglConfig ) { int[] attrib_list = { EGL_CONTEXT_CLIENT_VERSION 2,EGL10.EGL_NONE }; EGLContext context = egl.eglCreateContext(display, eglConfig, EGL10.EGL_NO_CONTEXT,attrib_list ); return context; } public void destroyContext( EGL10 egl,EGLDisplay display, EGLContext context ) { egl.eglDestroyContext( display, context ); } }
-
ConfigChooser
类处理像素格式。在本书中我们省略了所有错误检查;然而,在2_OpenGLES2
示例的GLView.java
文件中可以找到一个更健壮的实现:private static class ConfigChooser implementsGLSurfaceView.EGLConfigChooser { public ConfigChooser( int r, int g, int b, int a,int depth, int stencil ) … private static int EGL_OPENGL_ES2_BIT = 4;
-
我们像素格式选择器的默认值为:
private static int[] s_configAttribs2 = { EGL10.EGL_RED_SIZE, 5,EGL10.EGL_GREEN_SIZE, 6,EGL10.EGL_BLUE_SIZE, 5,EGL10.EGL_ALPHA_SIZE, 0,EGL10.EGL_DEPTH_SIZE, 16,EGL10.EGL_STENCIL_SIZE, 0,EGL10.EGL_SAMPLE_BUFFERS, 0,EGL10.EGL_SAMPLES, 0,EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,EGL10.EGL_NONE, EGL10.EGL_NONE }; public EGLConfig chooseConfig( EGL10 egl,EGLDisplay display ) { int[] num_config = new int[1]; egl.eglChooseConfig( display, s_configAttribs2,null, 0, num_config ); int numConfigs = num_config[0]; …
-
分配并读取最小匹配 EGL 配置的数组:
EGLConfig[] configs = new EGLConfig[numConfigs]; egl.eglChooseConfig( display, s_configAttribs2,configs, numConfigs, num_config );
-
选择最佳匹配项:
return chooseConfig( egl, display, configs ); } public EGLConfig chooseConfig( EGL10 egl,EGLDisplay display, EGLConfig[] configs ) { for ( EGLConfig config : configs ) {
-
选择具有指定深度缓冲区和模板缓冲区位的配置:
int d = findConfigAttrib( egl, display,config, EGL10.EGL_DEPTH_SIZE, 0 ); int s = findConfigAttrib( egl, display,config, EGL10.EGL_STENCIL_SIZE, 0 );
-
我们至少需要
mDepthSize
和mStencilSize
位来进行深度和模板处理:if ( d < mDepthSize || s < mStencilSize ) { continue; }
-
我们希望红/绿/蓝/透明位有一个完全匹配:
int r = findConfigAttrib( egl, display,config, EGL10.EGL_RED_SIZE, 0 ); int g = findConfigAttrib( egl, display,config, EGL10.EGL_GREEN_SIZE, 0 ); int b = findConfigAttrib( egl, display,config, EGL10.EGL_BLUE_SIZE, 0 ); int a = findConfigAttrib( egl, display,config, EGL10.EGL_ALPHA_SIZE, 0 ); if ( r == mRedSize && g == mGreenSize &&b == mBlueSize && a == mAlphaSize ) { return config; } } return null; }
-
使用辅助方法查找匹配的配置:
private int findConfigAttrib( EGL10 egl,EGLDisplay display, EGLConfig config,int attribute, int defaultValue ) { if ( egl.eglGetConfigAttrib( display,config, attribute, mValue ) ) { return mValue[0]; } return defaultValue; } … }
-
Renderer
类将帧渲染回调委托给我们的 NDK 代码:private static class Rendererimplements GLSurfaceView.Renderer { public void onDrawFrame( GL10 gl ) { App13Activity.DrawFrame(); } public void onSurfaceChanged( GL10 gl,int width, int height ) { App13Activity.SetSurfaceSize( width, height ); } public void onSurfaceCreated( GL10 gl,EGLConfig config ) { App13Activity.SetSurface(App13Activity.m_View.getHolder().getSurface() ); } } }
工作原理…
帧渲染回调在App13Activity.java
中声明:
public static native void SetSurface( Surface surface );
public static native void SetSurfaceSize(
int width, int height );
public static native void DrawFrame();
它们是Wrappers.cpp
文件中实现的 JNI 调用:
JNIEXPORT void JNICALL
Java_com_packtpub_ndkcookbook_app13_App13Activity_SetSurface(
JNIEnv* env, jclass clazz, jobject javaSurface )
{
if ( LGL3 ) { delete( LGL3 ); }
分配一个新的sLGLAPI
结构并重新加载 OpenGL 函数的指针:
LGL3 = new sLGLAPI;
LGL::clGLExtRetriever* OpenGL;
OpenGL = new LGL::clGLExtRetriever;
OpenGL->Reload( LGL3 );
delete( OpenGL );
}
JNIEXPORT void JNICALLJava_com_packtpub_ndkcookbook_app13_App13Activity_SetSurfaceSize(JNIEnv* env, jclass clazz, int Width, int Height )
{
更新表面大小。在这里我们不需要做其他任何事情,因为SetSurface()
将在其后立即被调用:
g_Width = Width;
g_Height = Height;
}
JNIEXPORT void JNICALLJava_com_packtpub_ndkcookbook_app13_App13Activity_DrawFrame(JNIEnv* env, jobject obj )
{
调用我们与平台无关的帧渲染回调:
OnDrawFrame();
}
现在,我们可以将渲染代码放在OnDrawFrame()
回调中,并在 Android 上使用它。
还有更多…
要使用之前讨论的代码,您需要在AndroidManifest.xml
文件中添加这一行:
<uses-feature android:glEsVersion="0x00020000"/>
此外,您需要将本地应用程序与 OpenGL ES 2 或 OpenGL ES 3 库链接。在您的Android.mk
文件中放入-lGLESv2
或-lGLESv3
开关,如下所示:
LOCAL_LDLIBS += -lGLESv2
注意
还有一种方法可以做到这一点。您可以省略静态链接,通过dlopen()
调用打开libGLESv2.so
共享库,并使用dlsym()
函数获取 OpenGL 函数的指针。如果您正在开发适用于 OpenGL ES 2 和 OpenGL ES 3 的通用渲染器,并希望运行时调整一切,这很有用。
另请参阅
- 统一 OpenGL 3 核心配置和 OpenGL ES 2
统一 GLSL 3 和 GLSL ES 2 着色器
OpenGL 3 支持 OpenGL 着色语言。特别是,OpenGL 3.2 Core Profile 支持 GLSL 1.50 Core Profile。另一方面,OpenGL ES 2 支持 GLSL ES 版本 1.0,而 OpenGL ES 3 支持 GLSL ES 3.0。这三个 GLSL 版本之间有轻微的语法差异,为了编写可移植的着色器,我们必须对这些差异进行抽象化处理。在本教程中,我们将创建一个设施,以将桌面 OpenGL 着色器降级,使其与 OpenGL ES 着色语言 1.0 兼容。
注意
OpenGL ES 3 对 OpenGL ES 着色语言 1.0 提供了向后兼容支持。为此,我们在着色器开头放置了#version 100
。然而,如果你的应用程序只针对最新的 OpenGL ES 3,你可以使用标记#version 300 es
并避免一些转换。更多详细信息,请参考 OpenGL ES 着色语言 3.0 的规格说明书,在www.khronos.org/registry/gles/specs/3.0/GLSL_ES_Specification_3.00.4.pdf
。
准备就绪
可以从官方 OpenGL 网站www.opengl.org
下载不同版本的 GLSL 语言规格说明书。GLSL 1.50 规格说明书可以在www.opengl.org/registry/doc/GLSLangSpec.1.50.09.pdf
找到。
GLSL ES 的规格说明书可以从 Khronos 网站www.khronos.org
下载。GLSL ES 1.0 规格说明书可在www.khronos.org/registry/gles/specs/2.0/GLSL_ES_Specification_1.0.17.pdf
获取。
如何操作…
-
让我们看看两组简单的顶点和片段着色器。适用于 GLSL 1.50 的是:
// vertex shader #version 150 core uniform mat4 in_ModelViewProjectionMatrix; in vec4 in_Vertex; in vec2 in_TexCoord; out vec2 Coords; void main() { Coords = in_TexCoord; gl_Position = in_ModelViewProjectionMatrix * in_Vertex; } // fragment shader #version 150 core in vec2 Coords; uniform sampler2D Texture0; out vec4 out_FragColor; void main() { out_FragColor = texture( Sampler0, Coords ); }
-
另一对着色器是针对 GLSL ES 1.0 的:
// vertex shader #version 100 precision highp float; uniform mat4 in_ModelViewProjectionMatrix; attribute vec4 in_Vertex; attribute vec2 in_TexCoord; varying vec2 Coords; void main() { Coords = in_TexCoord; gl_Position = in_ModelViewProjectionMatrix * in_Vertex; } // fragment shader #version 100 precision highp float; uniform sampler2D Texture0; varying vec2 Coords; void main() { gl_FragColor = texture2D( Texture0, Coords ); }
下表是 OpenGL API 三个版本之间一些差异的摘要,需要抽象化处理:
OpenGL 3 OpenGL ES 2 OpenGL ES 3 版本定义 #version 150 core #version 100 #version 300 es 显式浮点精度 不需要 需要 不需要 变量和属性的关键字 in 和 out varying 和 attribute in 和 out 固定功能片段数据位置 否,可定制 gl_FragColor 否,可定制 2D 纹理获取 texture(),重载 texture2D() texture(),重载 -
让我们在以下代码中实现转换规则,以将 GLSL 1.50 着色器降级到 GLSL 1.0:
#if defined( USE_OPENGL_3 ) std::string ShaderStr = "#version 150 core\n"; #else std::string ShaderStr = "#version 100\n"; ShaderStr += "precision highp float;\n"; ShaderStr += "#define USE_OPENGL_ES_2\n"; ShaderCodeUsed = Str_ReplaceAllSubStr( ShaderCodeUsed, "texture(", "texture2D(" ); if ( Target == GL_VERTEX_SHADER ) { ShaderCodeUsed = Str_ReplaceAllSubStr( ShaderCodeUsed,"in ", "attribute " ); ShaderCodeUsed = Str_ReplaceAllSubStr( ShaderCodeUsed,"out ", "varying " ); } if ( Target == GL_FRAGMENT_SHADER ) { ShaderCodeUsed = Str_ReplaceAllSubStr( ShaderCodeUsed,"out vec4 out_FragColor;", "" ); ShaderCodeUsed = Str_ReplaceAllSubStr( ShaderCodeUsed,"out_FragColor", "gl_FragColor" ); ShaderCodeUsed = Str_ReplaceAllSubStr( ShaderCodeUsed,"in ", "varying " ); } #endif
注意
这种搜索和替换暗示了对着色器源代码的一些限制。例如,它将使包含如
grayin
和sprout
等标识符的着色器无效。然而,上述代码非常简单,并且已经在几个已发布的商业项目中成功使用。
我们将着色器以 GLSL 1.5 源代码的形式存储,并在 Android 上通过简单的搜索和替换来使用它们。这样做非常简单且透明。
工作原理…
完整的实现包含在3_ShadersAndVertexArrays
示例中的clGLSLShaderProgram
类中。代码降级后,如有需要,它会被上传到 OpenGL:
GLuint Shader = LGL3->glCreateShader( Target );
const char* Code = ShaderStr.c_str();
LGL3->glShaderSource( Shader, 1, &Code, NULL );
LOGI( "Compiling shader for stage: %X\n", Target );
LGL3->glCompileShader( Shader );
CheckStatus()
函数执行错误检查,并在失败时记录指定的错误消息:
if ( !CheckStatus( Shader, GL_COMPILE_STATUS,"Failed to compile shader:" ) )
{
LGL3->glDeleteShader( Shader );
return OldShaderID;
}
if ( OldShaderID ) LGL3->glDeleteShader( OldShaderID );
return Shader;
OldShaderID
保留了上一个编译的着色器。它用于允许在 PC 上即时编辑着色器,并防止加载无效着色器。在顶点和片段着色器编译之后,应该链接着色器程序:
bool clGLSLShaderProgram::RelinkShaderProgram()
{
GLuint ProgramID = LGL3->glCreateProgram();
FVertexShaderID = AttachShaderID( GL_VERTEX_SHADER,FVertexShader, FVertexShaderID );
if ( FVertexShaderID ) LGL3->glAttachShader( ProgramID,FVertexShaderID );
FFragmentShaderID = AttachShaderID( GL_FRAGMENT_SHADER,FFragmentShader, FFragmentShaderID );
if ( FFragmentShaderID ) LGL3->glAttachShader( ProgramID,FFragmentShaderID );
BindDefaultLocations( ProgramID );
LGL3->glLinkProgram( ProgramID );
对着色器程序也应该执行相同的操作。只有当程序成功链接后,才替换旧的程序:
if ( !CheckStatus( ProgramID, GL_LINK_STATUS,"Failed to link program\n" ) )
{
LOGI( "Error during shader program relinking\n" );
return false;
}
LGL3->glDeleteProgram( FProgramID );
FProgramID = ProgramID;
RebindAllUniforms();
return true;
}
我们必须绑定将在整个渲染器中使用不同属性的默认位置:
void clGLSLShaderProgram::BindDefaultLocations( GLuint ID )
{
L_VS_
标识符的含义在操作几何图形的食谱中解释:
LGL3->glBindAttribLocation( ID, L_VS_VERTEX, "in_Vertex" );
LGL3->glBindAttribLocation( ID, L_VS_TEXCOORD,"in_TexCoord" );
LGL3->glBindAttribLocation( ID, L_VS_NORMAL, "in_Normal" );
LGL3->glBindAttribLocation( ID, L_VS_COLORS, "in_Color" );
LGL3->glBindFragDataLocation( ID, 0, "out_FragColor" );
LGL3->glUniform1i(LGL3->glGetUniformLocation( ID, "Texture0" ), 0 );
}
现在着色器程序可以用于渲染了。
还有更多…
在渲染过程中,我们可以通过名称指定附加统一变量的位置,并要求底层 OpenGL API 通过名称绑定统一变量。然而,在我们自己的代码中这样做更方便,因为我们可以省略多余的 OpenGL 状态更改调用。以下是RebindAllUniforms()
方法的清单,它将获取着色器程序中所有活跃统一变量的位置,并为以后的使用保存它们:
void clGLSLShaderProgram::RebindAllUniforms()
{
Bind();
FUniforms.clear();
GLint ActiveUniforms;
char Buff[256];
LGL3->glGetProgramiv( FProgramID,GL_ACTIVE_UNIFORMS, &ActiveUniforms );
for ( int i = 0; i != ActiveUniforms; ++i )
{
GLsizei Length;
GLint Size;
GLenum Type;
LGL3->glGetActiveUniform( FProgramID, i,sizeof( Buff ), &Length, &Size, &Type, Buff );
std::string Name( Buff, Length );
sUniform Uniform( Name );
Uniform.FLocation = LGL3->glGetUniformLocation(FProgramID, Name.c_str() );
FUniforms.push_back( Uniform );
}
}
sUniform
是一个struct
,它包含了一个活跃的统一变量:
struct sUniform
{
public:
explicit sUniform( const std::string& Name )
: FName( Name ), FLocation( -1 ) {}
sUniform( int Location, const std::string& Name )
: FName( Name ), FLocation( Location ) {}
std::string FName;
int FLocation;
};
它在许多SetUniformName()
函数中使用,以在运行时通过名称设置统一变量的值,而不接触 OpenGL API 来解决这些名称。
另请参阅
-
操作几何图形
-
统一顶点数组
-
创建立即渲染的画布
操作几何图形
在第四章,组织虚拟文件系统中,我们创建了Bitmap
类以 API 无关的方式加载和存储位图。现在,我们将创建一个类似的抽象,用于几何数据的表示,稍后我们将使用它将顶点和它们的属性提交给 OpenGL。
准备就绪
在我们继续进行抽象之前,让我们先看看 OpenGL 中顶点规范是如何工作的。向 OpenGL 提交顶点数据需要你创建不同的顶点流,并指定它们的解释方式。如果你不熟悉这个概念,请参考教程:www.opengl.org/wiki/Vertex_Specification
。
如何操作…
我们必须决定将哪些顶点属性,或者说顶点流,存储在我们的网格中。假设对于一个给定的顶点,我们需要位置、纹理坐标、法线和颜色。
以下是这些流的名称和索引:
const int L_VS_VERTEX = 0;
const int L_VS_TEXCOORD = 1;
const int L_VS_NORMAL = 2;
const int L_VS_COLORS = 3;
const int L_VS_TOTAL_ATTRIBS = L_VS_COLORS + 1;
注意
有时可能需要额外的纹理坐标,例如,在多纹理算法中,或者额外的属性,如切线、副法线,或者在硬件加速的 GPU 蒙皮中用到的骨骼和权重。这些属性可以通过这些语义轻易引入。我们将此作为一个练习留给读者。
-
让我们定义每个属性的浮点数组件数量:
const int VEC_COMPONENTS[ L_VS_TOTAL_ATTRIBS ] = { 3, 2, 3, 4 };
这意味着位置和法线以
vec3
表示,纹理坐标以vec2
表示,颜色以vec4
表示。我们需要这些信息以正确地在 OpenGL 着色器程序中定义类型并提交顶点数据。以下是我们用于顶点属性的渲染 API 无关容器的源代码:class clVertexAttribs: public iObject { public: clVertexAttribs(); clVertexAttribs( size_t Vertices ); void SetActiveVertexCount( size_t Count ){ FActiveVertexCount = Count; } size_t GetActiveVertexCount() const{ return FActiveVertexCount; }
-
我们需要一个方法将我们的顶点属性映射到枚举流:
const std::vector<const void*>& EnumerateVertexStreams();
-
我们还需要一些辅助方法来构建几何体:
void Restart( size_t ReserveVertices ); void EmitVertexV( const LVector3& Vec ); void EmitVertex( float X, float Y, float Z ){ EmitVertexV( LVector3(X,Y,Z) ); }; void SetTexCoord( float U, float V, float W ){ SetTexCoordV( LVector2(U,V) ); }; void SetTexCoordV( const LVector2& V ); void SetNormalV( const LVector3& Vec ); void SetColorV( const LVector4& Vec );
-
实际数据持有者为了方便被设置为
public
:public: // position X, Y, Z std::vector<LVector3> FVertices; // texture coordinate U, V std::vector<LVector2> FTexCoords; // normal in object space std::vector<LVector3> FNormals; // RGBA color std::vector<LVector4> FColors; … };
它是如何工作的…
为了使用clVertexAttribs
并向其填充有用的数据,我们声明了一些辅助函数:
clPtr<clVertexAttribs> CreateTriangle2D( float vX, float vY,float dX, float dY, float Z );
clPtr<clVertexAttribs> CreateRect2D( float X1, float Y1, float X2,float Y2, float Z, bool FlipTexCoordsVertical,int Subdivide );
clPtr<clVertexAttribs> CreateAxisAlignedBox( const LVector3& Min,const LVector3& Max );
clPtr<clVertexAttribs> CreatePlane( float SizeX, float SizeY,int SegmentsX, int SegmentsY, float Z );
以下是这些定义中的一个示例:
clPtr<clVertexAttribs> clGeomServ::CreateTriangle2D( float vX,
float vY, float dX, float dY, float Z )
{
clPtr<clVertexAttribs> VA = new clVertexAttribs();
重新开始生成并分配3
个顶点的空间:
VA->Restart( 3 );
VA->SetNormalV( LVector3( 0, 0, 1 ) );
VA->SetTexCoord( 1, 1, 0 );
VA->EmitVertexV( LVector3( vX , vY , Z ) );
VA->SetTexCoord( 1, 0, 0 );
VA->EmitVertexV( LVector3( vX , vY - dY, Z ) );
VA->SetTexCoord( 0, 1, 0 );
VA->EmitVertexV( LVector3( vX + dX, vY , Z ) );
return VA;
}
这些函数的完整源代码可以在3_ShadersAndVertexArrays
项目的GeomServ.cpp
文件中找到。现在我们有一组方便的函数来创建简单的 2D 和 3D 几何原始物体,如单个三角形、矩形和盒子。
还有更多…
如果你想要学习如何创建更复杂的 3D 原始物体,请下载 Linderdaum Engine 的源代码(www.linderdaum.com
)。在Geometry/GeomServ.h
中,你会发现如何生成球体、管子、多面体、齿轮和其他 3D 物体。
另请参阅
- 统一顶点数组
统一顶点数组
几何数据通过顶点缓冲对象(VBO)和顶点数组对象(VAO)提交到 OpenGL 中。VBO 是 OpenGL 版本的组成部分;然而,VAO 不是 OpenGL ES 2 的一部分,但在 OpenGL 3.2 核心配置中是必须的。这意味着我们不得不进行另一层抽象,以隐藏两个 API 之间的差异。
一个顶点缓冲对象(VBO)是 OpenGL 的一个特性,它提供了上传顶点数据(位置、法线向量、颜色等)到视频设备的方法,用于非立即模式渲染。VBOs 比立即模式渲染提供了实质性的性能提升,主要是因为数据位于视频设备内存中,而不是系统内存中,因此可以直接由视频设备渲染。
出处:en.wikipedia.org/wiki/Vertex_Buffer_Object
一个顶点数组对象(VAO)是一个封装了指定顶点数据所需状态的 OpenGL 对象。它们定义了顶点数据的格式以及顶点数组的数据源。VAOs 不包含数组本身;数组存储在缓冲区对象中。VAOs 只是引用已经存在的缓冲对象。
致谢:www.opengl.org/wiki/Vertex_Specification
准备就绪
在继续使用顶点数组之前,请确保你已经熟悉了前一个食谱中与平台无关的几何存储。本食谱的源代码可以在4_Canvas
示例中的GLVertexArray.cpp
和GLVertexArray.h
文件中找到。
如何操作…
-
我们的顶点数组隐藏在
clGLVertexArray
类的接口后面:class clGLVertexArray: public iObject { public: clGLVertexArray(); virtual ~clGLVertexArray(); void Draw( bool Wireframe ) const; void SetVertexAttribs(const clPtr<clVertexAttribs>&Attribs); private: void Bind() const; GLuint FVBOID; GLuint FVAOID;
-
通过以下代码存储 VBO 的偏移量:
std::vector<const void*> FAttribVBOOffset;
-
以下是附加的
clVertexAttribs
实际数据的指针:std::vector<const void*> FEnumeratedStreams; clPtr<clVertexAttribs> FAttribs; };
-
应该使用
SetVertexAttribs()
方法将clVertexAttribs
附加到我们的顶点数组上:void clGLVertexArray::SetVertexAttribs( constclPtr<clVertexAttribs>& Attribs ) { FAttribs = Attribs; FEnumeratedStreams = FAttribs->EnumerateVertexStreams();
-
我们必须在使用
FVBOID
之前移除任何旧的顶点缓冲对象,以允许重用clGLVertexArray
:LGL3->glDeleteBuffers( 1, &FVBOID ); size_t VertexCount = FAttribs->FVertices.size(); size_t DataSize = 0; for ( int i = 0; i != L_VS_TOTAL_ATTRIBS; i++ ) { FAttribVBOOffset[ i ] = ( void* )DataSize;
-
计算顶点缓冲对象的大小并分配它:
DataSize += FEnumeratedStreams[i] ?sizeof( float ) * L_VS_VEC_COMPONENTS[ i ] * VertexCount : 0; } LGL3->glGenBuffers( 1, &FVBOID ); LGL3->glBindBuffer( GL_ARRAY_BUFFER, FVBOID ); LGL3->glBufferData( GL_ARRAY_BUFFER, DataSize,NULL, GL_STREAM_DRAW );
-
为每个顶点属性提交数据:
for ( int i = 0; i != L_VS_TOTAL_ATTRIBS; i++ ) { LGL3->glBufferSubData( GL_ARRAY_BUFFER,(GLintptrARB)FAttribVBOOffset[ i ],FAttribs->GetActiveVertexCount() *sizeof( float ) * L_VS_VEC_COMPONENTS[ i ],FEnumeratedStreams[ i ] ); }
-
如果我们不在 Android 上,这里将创建 VAO:
#if !defined( ANDROID ) LGL3->glBindVertexArray( FVAOID ); Bind(); LGL3->glBindVertexArray( 0 ); #endif }
注意
VAO 可以与 OpenGL ES 3 一起使用。我们将它们的实现留给读者作为一个简单的练习。这可以通过为 OpenGL ES 3 使用 OpenGL 3 的代码路径来完成。
工作原理…
Bind()
方法负责实际绑定顶点缓冲对象并准备属性指针:
void clGLVertexArray::Bind() const
{
LGL3->glBindBuffer( GL_ARRAY_BUFFER, FVBOID );
LGL3->glVertexAttribPointer( L_VS_VERTEX,L_VS_VEC_COMPONENTS[ 0 ], GL_FLOAT, GL_FALSE, 0,FAttribVBOOffset[ 0 ] );
LGL3->glEnableVertexAttribArray( L_VS_VERTEX );
for ( int i = 1; i < L_VS_TOTAL_ATTRIBS; i++ )
{
LGL3->glVertexAttribPointer( i,L_VS_VEC_COMPONENTS[ i ],GL_FLOAT, GL_FALSE, 0,FAttribVBOOffset[ i ] );
FAttribVBOOffset[ i ] ?LGL3->glEnableVertexAttribArray( i ) :LGL3->glDisableVertexAttribArray( i );
}
}
现在,我们可以通过Draw()
方法渲染几何图形:
void clGLVertexArray::Draw( bool Wireframe ) const
{
#if !defined( ANDROID )
LGL3->glBindVertexArray( FVAOID );
#else
Bind();
#endif
LGL3->glDrawArrays( Wireframe ? GL_LINE_LOOP : GL_TRIANGLES,0, static_cast<GLsizei>(FAttribs->GetActiveVertexCount() ) );
}
再次提醒,有一个#define
用于在 Android 上禁用 VAO。以下是在本章所有前一个食谱的技术基础上,使用3_ShadersAndVertexArrays
示例渲染一个动画旋转立方体的截图:
还有更多…
我们始终假设在所有示例中,每个顶点属性(位置、纹理坐标、法线和颜色)都存在于几何数据中。实际上,这对于我们的clVertexAttribs
实现来说总是正确的。然而,在更复杂的情况下,例如你可能需要更多的顶点属性,比如副法线、切线、骨骼权重等,为未使用的属性分配内存是不明智的。这可以通过修改clVertexAttribs::EnumerateVertexStreams()
成员函数,并在Bind()
和SetVertexAttribs()
中添加 NULL 检查来实现。
另请参阅
- 操纵几何图形
创建纹理的包装器
在前面的章节中,我们已经使用 OpenGL 纹理将离屏帧缓冲区渲染到屏幕上。但是,那条代码路径只在 Android 上有效,不能在桌面上使用。在本食谱中,我们将创建一个纹理包装器,使它们可移植。
准备就绪
查看4_Canvas
中的GLTexture.cpp
和GLTexture.h
文件。
如何操作…
-
让我们声明一个类来保存 OpenGL 纹理。我们只需要两个公共操作:从位图中加载像素数据,以及将纹理绑定到指定的 OpenGL 纹理单元:
class clGLTexture { public: clGLTexture(); virtual ~clGLTexture(); void Bind( int TextureUnit ) const; void LoadFromBitmap( const clPtr<clBitmap>& B ); private: GLuint FTexID; GLenum FInternalFormat; GLenum FFormat; }
-
这个类的接口非常简单,因为 OpenGL ES 2 和 OpenGL 3 中的纹理管理几乎相同。所有差异都在实现中。以下代码展示了我们如何绑定纹理:
void clGLTexture::Bind( int TextureUnit ) const { LGL3->glActiveTexture( GL_TEXTURE0 + TextureUnit ); LGL3->glBindTexture( GL_TEXTURE_2D, FTexID ); }
-
我们通过以下代码从位图加载纹理:
void clGLTexture::LoadFromBitmap( const clPtr<clBitmap>& B ) { if ( !FTexID ) LGL3->glGenTextures( 1, &FTexID ); ChooseInternalFormat( B->FBitmapParams, &FFormat,&FInternalFormat ); Bind( 0 ); LGL3->glTexParameteri( GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER, GL_LINEAR ); LGL3->glTexParameteri( GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER, GL_LINEAR );
-
OpenGL ES 2 并不支持所有的纹理包装模式。特别是,
GL_CLAMP_TO_BORDER
是不支持的:#if defined( ANDROID ) LGL3->glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE ); LGL3->glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE ); #else LGL3->glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,GL_CLAMP_TO_BORDER ); LGL3->glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,GL_CLAMP_TO_BORDER ); #endif LGL3->glTexImage2D( GL_TEXTURE_2D, 0, FInternalFormat,B->GetWidth(), B->GetHeight(), 0, FFormat,GL_UNSIGNED_BYTE, B->FBitmapData ); }
-
有一个辅助函数
ChooseInternalFormat()
,我们用它来为位图选择合适的 OpenGL 图像格式,无论是 RGB 还是 RGBA。实现如下所示代码:bool ChooseInternalFormat( const sBitmapParams& BMPRec, GLenum* Format, GLenum* InternalFormat ) { if ( BMPRec.FBitmapFormat == L_BITMAP_BGR8 ) { #if defined( ANDROID ) *InternalFormat = GL_RGB; *Format = GL_RGB; #else *InternalFormat = GL_RGB8; *Format = GL_BGR; #endif }
-
这同样适用于包含 alpha 通道的 RGBA 位图:
if ( BMPRec.FBitmapFormat == L_BITMAP_BGRA8 ) { #if defined( ANDROID ) *InternalFormat = GL_RGBA; *Format = GL_RGBA; #else *InternalFormat = GL_RGBA8; *Format = GL_BGRA; #endif } return false; }
这个函数可以很容易地扩展以支持灰度、浮点数和压缩格式。
工作原理…
使用我们的纹理包装器非常直接:
clPtr<clBitmap> Bmp = clBitmap::LoadImg(g_FS->CreateReader("test.bmp") );
Texture = new clGLTexture();
Texture->LoadFromBitmap( Bmp );
在这里,g_FS
是一个在第五章,跨平台音频流中创建的FileSystem
对象。
还有更多…
到目前为止我们处理的纹理加载是同步的,并且是在主渲染线程上执行的。如果我们只有几个位图要加载,这是可以接受的。现实世界的方法是在另一个线程上异步加载和解码图像,然后在渲染线程上只调用glTexImage2D()
和其他相关的 OpenGL 命令。我们将在第九章,编写一个拼图游戏中学习如何做到这一点。
另请参阅
-
第四章,组织一个虚拟文件系统
-
第九章,编写一个拼图游戏
创建一个用于即时渲染的画布
在前面的食谱中,我们学习了如何为主要的 OpenGL 实体制作抽象:顶点缓冲区、着色器程序和纹理。这个基础足以使用 OpenGL 渲染许多复杂的特效。然而,有许多小的渲染任务,你只需要渲染一个三角形或带有一个纹理的矩形,或者使用特定的着色器渲染一个全屏四边形以应用一些图像空间效果。在这种情况下,管理缓冲区、着色器和纹理的代码可能成为一个严重的负担。让我们为这样的辅助代码组织一个地方,即一个画布,它可以帮助我们用一行代码渲染简单的事物。
准备就绪
本食谱使用了前一个食谱中描述的clGLSLShaderProgram
、clGLTexture
和clGLVertexArray
类,以隐藏 OpenGL ES 2 和 OpenGL 3 之间的差异。在继续之前请仔细阅读它们。
如何操作…
-
我们首先定义一个
clCanvas
类如下:class clCanvas { public: clCanvas(); virtual ~clCanvas() {}; void Rect2D( float X1, float Y1,float X2, float Y2, const LVector4& Color ); void TexturedRect2D( float X1, float Y1,float X2, float Y2,const LVector4& Color,const clPtr<clGLTexture>& Texture ); clPtr<clGLVertexArray> GetFullscreenRect() const { return FRectVA; }
-
我们在这里存储一些与 OpenGL 相关的实体:
private: clPtr<clVertexAttribs> FRect; clPtr<clGLVertexArray> FRectVA; clPtr<clGLSLShaderProgram> FRectSP; clPtr<clGLSLShaderProgram> FTexRectSP; };
-
在使用画布之前,我们必须构建它。注意
FRect
是作为一个全屏四边形创建的:clCanvas::clCanvas() { FRect = clGeomServ::CreateRect2D( 0.0f, 0.0f,1.0f, 1.0f, 0.0f, false, 1 ); FRectVA = new clGLVertexArray(); FRectVA->SetVertexAttribs( FRect ); FRectSP = new clGLSLShaderProgram( RectvShaderStr,RectfShaderStr ); FTexRectSP = new clGLSLShaderProgram( RectvShaderStr, TexRectfShaderStr ); }
-
我们在下面的顶点着色器中重新映射
FRect
的坐标,使它们与用户指定的尺寸相匹配:uniform vec4 u_RectSize; in vec4 in_Vertex; in vec2 in_TexCoord; out vec2 Coords; void main() { Coords = in_TexCoord; float X1 = u_RectSize.x; float Y1 = u_RectSize.y; float X2 = u_RectSize.z; float Y2 = u_RectSize.w; float Width = X2 - X1; float Height = Y2 - Y1; vec4 VertexPos = vec4( X1 + in_Vertex.x * Width,Y1 + in_Vertex.y * Height,in_Vertex.z, in_Vertex.w ) *vec4( 2.0, -2.0, 1.0, 1.0 ) +vec4( -1.0, 1.0, 0.0, 0.0 ); gl_Position = VertexPos; }
-
实际尺寸,指定为矩形的左上角和右下角,作为
u_RectSize
统一变量的xyzw
组件传递。简单的算术完成了剩余的工作。片段着色器非常简单。实际上,我们只需要从统一变量中应用一种纯色:uniform vec4 u_Color; out vec4 out_FragColor; in vec2 Coords; void main() { out_FragColor = u_Color; }
-
或者,从纹理应用额外的颜色:
uniform vec4 u_Color; out vec4 out_FragColor; in vec2 Coords; uniform sampler2D Texture0; void main() { out_FragColor = u_Color * texture( Texture0, Coords ); }
我们使用前一个食谱中的clGLSLShaderProgram
类来设置着色器程序。它隐藏了 OpenGL ES 2 和 OpenGL 3 之间的语法差异,因此我们可以只存储每个着色器的一个版本。
注意
你可以尝试实现一个类似 OpenGL ES 3 的包装器作为练习。
它是如何工作的…
-
画布内部的实际渲染代码非常简单。绑定纹理和着色器程序,设置统一变量的值,并绘制顶点数组:
void clCanvas::TexturedRect2D( float X1, float Y1, float X2, float Y2, const LVector4& Color, const clPtr<clGLTexture>& Texture ) { LGL3->glDisable( GL_DEPTH_TEST ); Texture->Bind(0); FTexRectSP->Bind(); FTexRectSP->SetUniformNameVec4Array( "u_Color", 1, Color ); FTexRectSP->SetUniformNameVec4Array( "u_RectSize", 1, LVector4( X1, Y1, X2, Y2 ) ); LGL3->glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA ); LGL3->glEnable( GL_BLEND ); FRectVA->Draw( false ); LGL3->glDisable( GL_BLEND ); }
注意
在这里我们总是启用和禁用混合。这导致了冗余的状态变化。更好的方法是保存先前设置的混合模式的值,并且只在必要时切换它。
完整的源代码在4_Canvas
项目中的Canvas.cpp
和Canvas.h
文件中。画布的使用非常简单。例如,使用以下单行调用以渲染半透明的洋红色矩形:
Canvas->Rect2D( 0.1f, 0.1f, 0.5f, 0.5f, vec4( 1, 0, 1, 0.5f ) );
示例4_Canvas
展示了如何使用画布,并生成与以下类似的图像,该图像展示了使用Canvas
的叠加渲染:
还有更多…
画布为不同的即时渲染函数提供了占位符。在接下来的两章中,我们将为其增加其他方法,以渲染我们游戏的用户界面。
另请参阅
-
统一 OpenGL 3 核心配置文件和 OpenGL ES 2
-
统一 GLSL 3 和 GLSL ES 2 着色器
-
统一顶点数组
-
为纹理创建包装器
第七章:跨平台 UI 和输入系统
在本章中,我们将涵盖:
-
在安卓上处理多触摸事件
-
在 Windows 上设置多触摸模拟
-
在 Windows 上处理多触摸事件
-
识别手势
-
实现屏幕上的游戏手柄
-
使用 FreeType 进行文本渲染
-
游戏内字符串的本地化
引言
移动用户界面基于(除了图形渲染)多触摸输入。本章将向您展示如何在安卓操作系统上处理触摸事件,以及如何在 Windows 上调试它们。还包含了一个关于在 Windows 上使用多个鼠标模拟多触摸能力的专门教程。本章的其余部分致力于高质量文本渲染和支持多种语言。
在安卓上处理多触摸事件
迄今为止,我们还没有处理除了安卓上的返回按钮之外的任何用户交互。在本教程中,我们将展示如何处理安卓上的多触摸事件。
准备就绪
你应该熟悉多触摸输入处理的概念。在 Java 中,安卓多触摸事件是在MotionEvent
类内部传递的,该类的实例作为参数传递给你的Activity
类的onTouchEvent()
方法。MotionEvent
类包含了所有当前活动中和已释放的触摸信息。为了将此信息传递给我们的本地代码,我们将携带多个触摸的单个事件转换为一系列仅包含单个触摸数据的事件。这简化了 JNI 的交互操作,并使我们的代码易于移植。
如何操作...
每个安卓活动都支持多触摸事件处理。我们所要做的就是重写Activity
类的onTouchEvent()
方法:
-
首先,我们声明一些与单个触摸点相关的事件的内部常量:
private static final int MOTION_MOVE = 0; private static final int MOTION_UP = 1; private static final int MOTION_DOWN = 2; private static final int MOTION_START = -1; private static final int MOTION_END = -2;
-
事件处理器使用
MotionEvent
结构,并提取有关单个触摸的信息。在本地代码中声明的SendMotion()
函数包含了我们通过 JNI 从onTouchEvent()
中调用的手势解码:@Override public boolean onTouchEvent( MotionEvent event ) {
-
告诉我们的本地代码我们将要发送一系列事件:
SendMotion( MOTION_START, 0, 0, false, MOTION_MOVE );
-
确定事件代码和第一个触摸点的
ID
:int E = event.getAction() & MotionEvent.ACTION_MASK; int nPointerID = event.getPointerId((event.getAction() &MotionEvent.ACTION_POINTER_INDEX_MASK) >>MotionEvent.ACTION_POINTER_INDEX_SHIFT ); try {
-
获取主触摸点的坐标:
int x = (int)event.getX(), y = (int)event.getY(); int cnt = event.getPointerCount();
-
处理触摸开始:
if ( E == MotionEvent.ACTION_DOWN ) { for ( int i = 0; i != cnt; i++ ) SendMotion( event.getPointerId(i),(int)event.getX(i),(int)event.getY(i),true, MOTION_DOWN ); }
-
当所有触摸点释放时,处理整个手势的结束:
if ( E == MotionEvent.ACTION_UP ||E == MotionEvent.ACTION_CANCEL ) { SendMotion( MOTION_END, 0, 0, false, MOTION_UP ); return E <= MotionEvent.ACTION_MOVE; }
-
处理次要触摸点:
int maskedEvent = event.getActionMasked(); if ( maskedEvent== MotionEvent.ACTION_POINTER_DOWN ) { for ( int i = 0; i != cnt; i++ ) SendMotion( event.getPointerId(i),(int)event.getX(i),(int)event.getY(i),true, MOTION_DOWN ); } if ( maskedEvent == MotionEvent.ACTION_POINTER_UP ) { for ( int i = 0; i != cnt ; i++ ) SendMotion( event.getPointerId(i),(int)event.getX(i),(int)event.getY(i),i != nPointerID, MOTION_UP ); SendMotion( nPointerID,(int)event.getX(nPointerID),(int)event.getY(nPointerID),false, MOTION_MOVE ); }
-
最后,我们更新每个触摸点的坐标:
if ( E == MotionEvent.ACTION_MOVE ) { for ( int i = 0; i != cnt; i++ ) SendMotion(event.getPointerId(i),(int)event.getX(i),(int)event.getY(i),true, MOTION_MOVE ); } }
-
当所有操作完成后,我们通知本地手势解码器事件序列的结束:
SendMotion( MOTION_END, 0, 0, false, MOTION_MOVE ); return E <= MotionEvent.ACTION_MOVE; }
-
本地
SendMotion()
函数接受触摸点ID
、屏幕像素坐标、运动标志和一个表示触摸点是否激活的布尔参数:public native static void SendMotion( int PointerID, int x, int y, boolean Pressed, int Flag );
工作原理...
安卓操作系统将触摸点的通知发送到我们的应用程序,onTouchEvent()
函数将包含在MotionEvent
对象中的触摸事件集合转换为一连串的 JNI SendMotion()
调用。
另请参阅
-
在 Windows 上处理多触摸事件
-
识别手势
在 Windows 上设置多点触控仿真
没有硬件的情况下测试基于触摸的界面是很困难的,但即使有可用的 Android 硬件,我们也没有逐步调试器的奢侈。幸运的是,Windows 支持触摸屏硬件,可以为我们的应用程序提供WM_TOUCH
事件。这个方法展示了一个技巧,利用多只鼠标来模拟触摸事件。
准备就绪
本方法依赖于第三方 Windows 驱动程序,即 MultiTouchVista,它是一个用户输入管理层,处理来自各种设备的输入。可以从multitouchvista.codeplex.com/
下载。
如何操作...
-
首先,我们需要安装系统驱动。我们解压
MultiTouchVista_-_second_release_-_refresh_2.zip
文件,这是在撰写本文时最新的版本,然后用管理员权限打开命令行。如果未以管理员权限运行控制台,驱动程序安装将会失败。解压后的文件夹包含一个名为Driver
的子文件夹,你应根据操作系统的类型选择x64
或x32
文件夹。在那个文件夹中,我们执行以下命令:>Install driver.cmd
-
会弹出一个对话框,询问你是否想要安装这个设备软件,你应该点击安装按钮。安装完成后,你将在命令行上看到一条消息。
-
接下来我们要做的是在设备管理器中激活驱动。我们打开控制面板,然后打开设备管理器窗口。在那里,我们在列表中找到人体学输入设备项。我们右键点击刚刚安装了驱动程序的通用软件 HID 设备。从上下文菜单中选择禁用以禁用该设备。在禁用设备前的确认中,我们只需回答是。之后,我们再次通过右键点击这个节点并选择启用来重新启用这个设备。
-
现在,由于我们使用鼠标模拟多点触控,我们应该在屏幕上以某种方式显示触摸点,因为否则不可能知道鼠标指针的位置。在控制面板 | 硬件和声音中,我们打开笔和触摸窗口。触摸选项卡包含当我与屏幕上的项目互动时显示触摸指针复选框,应该启用它。
-
当所有鼠标都连接后,我们可以启动驱动程序。我们打开两个命令行窗口,在第一个窗口中运行来自
MultiTouchVista
软件包的Multitouch.Service.Console.exe
。在第二个控制台窗口中,我们运行Multitouch.Driver.Console.exe
,同时不要关闭MultiTouch.Server.Console窗口。退出这两个应用程序,以返回到正常的非多点触控 Windows 环境。
它是如何工作的...
为了检查驱动程序和服务是否如预期般工作,我们可以尝试使用标准微软画图应用程序,并使用两只或多只鼠标同时绘制一些内容。
另请参阅
- 在 Windows 上处理多点触控事件
在 Windows 上处理多点触控事件
安装了MultiTouchVista
驱动后,或者如果我们恰好有一个支持多点触控的屏幕,我们可以在应用程序中初始化一个事件循环并处理WM_TOUCH
消息。
准备就绪
第一个食谱包含了关于多点触控处理的所有相关信息。在这个食谱中,我们仅扩展了针对 Microsoft Windows 的代码。
注意
本书没有讨论关于 Mac 的多点触控输入模拟。
如何操作...
-
MinGW
工具链不包括最新的 Windows SDK 头文件,因此需要定义许多常量以使用WM_TOUCH
消息:#if !defined(_MSC_VER) #define SM_DIGITIZER 94 #define SM_MAXIMUMTOUCHES 95 #define TOUCHEVENTF_DOWN 0x0001 #define TOUCHEVENTF_MOVE 0x0002 #define TOUCHEVENTF_UP 0x0004 #define TOUCHEVENTF_PRIMARY 0x0010 #define WM_TOUCH 0x0240
-
TOUCHINPUT
结构使用WinAPI
数据类型封装了一个单独的触摸,并且也应该为MinGW
手动声明:typedef struct _TOUCHINPUT { LONG x, y; HANDLE hSource; DWORD dwID, dwFlags, wMask, dwTime; ULONG_PTR dwExtraInfo; DWORD cxContact, cyContact; } TOUCHINPUT,*PTOUCHINPUT; #endif
-
接下来的四个函数为我们的应用程序提供了触摸界面处理。我们声明函数原型和静态函数指针,以便从
user32.dll
加载它们:typedef BOOL (WINAPI *CloseTouchInputHandle_func)(HANDLE); typedef BOOL (WINAPI *Get_func)(HANDLE, UINT, PTOUCHINPUT, int); typedef BOOL (WINAPI *RegisterTouch_func)(HWND, ULONG); typedef BOOL (WINAPI *UnregisterTouch_func)(HWND); static CloseTouch_func CloseTouchInputHandle_Ptr = NULL; static Get_func GetTouchInputInfo_Ptr = NULL; static RegisterTouch_func RegisterTouchWindow_Ptr = NULL; static UnregisterTouch_func UnregisterTouchWindow_Ptr =NULL;
-
由于
MinGW
不支持自动导出与WM_TOUCH
相关的方法,我们必须使用GetProcAddress()
手动从user32.dll
加载它们。这一操作在1_MultitouchInput
中的Wrapper_Windows.cpp
文件中定义的LoadTouchFuncs()
函数中完成:static bool LoadTouchFuncs() { if ( !CloseTouchInputHandle_Ptr ) { HMODULE hUser = LoadLibraryA( "user32.dll" ); CloseTouchInputHandle_Ptr =(CloseTouchInputHandle_func)GetProcAddress( hUser, "CloseTouchInputHandle" ); GetTouchInputInfo_Ptr = ( GetTouchInputInfo_func )GetProcAddress( hUser, "GetTouchInputInfo" ); RegisterTouchWindow_Ptr = (RegisterTouchWindow_func)GetProcAddress( hUser, "RegisterTouchWindow" ); UnregisterTouchWindow_Ptr =(UnregisterTouchWindow_func)GetProcAddress( hUser, "UnregisterTouchWindow" ); } return ( RegisterTouchWindow_Ptr != NULL ); }
-
最后,我们需要声明
GetTouchPoint()
例程,它将TOUCHPOINT
坐标转换为屏幕像素,为了简单起见,这里使用了硬编码的窗口大小 100 x 100 像素:static POINT GetTouchPoint(HWND hWnd, const TOUCHINPUT& ti) { POINT pt; pt.x = ti.x / 100; pt.y = ti.y / 100; ScreenToClient( hWnd, &pt ); return pt; }
-
现在,我们准备在 Windows 上实现多点触控消息处理。在我们的窗口函数中,我们为
WM_TOUCH
消息添加一个新的消息处理程序,其中包含了打包在一起的不同触摸点的数据。我们将参数解包到一个数组中,其中每个条目代表单个触摸点的消息:case WM_TOUCH: { unsigned int NumInputs = (unsigned int)wParam; if ( NumInputs < 1 ) { break; } TOUCHINPUT* ti = new TOUCHINPUT[NumInputs]; DWORD Res = GetTouchInputInfo_Ptr((HANDLE)lParam, NumInputs, ti, sizeof(TOUCHINPUT)); double EventTime = Env_GetSeconds(); if ( !Res ) { break; }
-
对于每个触摸点,我们在全局数组
g_TouchPoints
中更新其状态。这是与 Android 代码的主要区别,因为在 Java 代码中我们会解码MotionEvent
结构体,并将点列表传递给本地代码:for (unsigned int i = 0; i < NumInputs ; ++i) { POINT touch_pt = GetTouchPoint(Window, ti[i]); vec2 Coord(touch_pt.x / ImageWidth,touch_pt.y / ImageHeight); sTouchPoint pt(ti[i].dwID, Coord,MOTION_MOVE, EventTime); if (ti[i].dwFlags & TOUCHEVENTF_DOWN)pt.FFlag = MOTION_DOWN; if (ti[i].dwFlags & TOUCHEVENTF_UP) pt.FFlag = MOTION_UP; Viewport_UpdateTouchPoint(pt); }
-
然后,我们清理临时数组:
CloseTouchInputHandle_Ptr((HANDLE)lParam); delete[] ti;
-
我们移除所有释放的点:
Viewport_ClearReleasedPoints();
-
最后,我们处理所有活动的触摸点:
Viewport_UpdateCurrentGesture(); break; }
-
事件处理程序使用一个全局触摸点列表:
std::list<sTouchPoint> g_TouchPoints;
-
sTouchPoint
结构体封装了一个触摸点的坐标、触摸点ID
、运动标志和关联的事件时间戳:struct sTouchPoint { int FID; vec2 FPoint; int FFlag; double FTimeStamp; sTouchPoint(int ID, const vec2& C, int flag, doubletstamp): FID(ID), FPoint(c), FFlag(flag), FTimeStamp(tstamp) {}
-
检查这个触摸点是否处于激活状态:
inline bool IsPressed() const { return (FFlag == MOTION_MOVE) || (FFlag ==MOTION_DOWN); } };
-
Viewport_UpdateTouchPoint()
函数会根据运动标志将点添加到列表中,或者只是更新状态:void Viewport_UpdateTouchPoint(const sTouchPoint& pt) { std::list<sTouchPoint>::iterator foundIt =FTouchPoints.end(); for ( auto it = FTouchPoints.begin(); it != foundIt;++it ) { if ( it->FID == pt.FID ) { foundIt = it; break; } } switch ( pt.FFlag ) { case MOTION_DOWN: if ( foundIt == FTouchPoints.end() ) FTouchPoints.push_back( pt ); case MOTION_UP: case MOTION_MOVE: if ( foundIt != FTouchPoints.end() ) *foundIt = pt; break; } }
-
Viewport_ClearReleasedPoints()
函数移除所有运动标志设置为MOTION_UP
的点:void Viewport_ClearReleasedPoints() { auto first = FTouchPoints.begin(); auto result = first; for ( ; first != FTouchPoints.end() ; ++first ) if ( first->FFlag != MOTION_UP ) *result++ = *first; FTouchPoints.erase( result, FTouchPoints.end() ); }
-
最后一个函数,
Viewport_UpdateCurrentGesture()
,将点列表发送到手势处理器:void Viewport_UpdateCurrentGesture() { Viewport_ProcessMotion( MOTION_START,vec2(), false, MOTION_MOVE ); auto j = FTouchPoints.begin(); for ( ; j != FTouchPoints.end(); ++j ) Viewport_ProcessMotion( j->FID, j->FPoint,j->IsPressed(), j->FFlag ); Viewport_ProcessMotion( MOTION_END, vec2(), false,MOTION_MOVE ); }
工作原理...
在WM_CREATE
事件处理程序中,我们将我们的窗口注册为触摸事件响应者:
case WM_CREATE:
...
g_TouchEnabled = false;
BYTE DigitizerStatus = (BYTE)GetSystemMetrics( SM_DIGITIZER );
if ( (DigitizerStatus & (0x80 + 0x40)) != 0 )
{
BYTE nInputs = (BYTE)GetSystemMetrics( SM_MAXIMUMTOUCHES );
if ( LoadTouchFuncs() )
{
if ( !RegisterTouchWindow_Ptr(h, 0) )
{
LOGI( "Enabled, num points: %d\n", (int)nInputs );
g_TouchEnabled = true;
break;
}
}
}
然后,我们在Viewport_ProcessMotion()
函数中获取一系列触摸事件。
还有更多...
Windows 8 引入了WM_POINTER
消息,这确保了代码更加整洁,类似于 Android 和其他基于触摸的环境。感兴趣的读者可以阅读相应的 MSDN 文章(msdn.microsoft.com/en-us/library/hh454928(v=vs.85).aspx
),并在窗口函数中编写类似的处理程序。
另请参阅
1_MultitouchInput
示例中包含了WM_TOUCH
消息处理代码。下一个食谱将展示如何解码一系列的多点触控事件并识别一些基本的手势。
识别手势
在这个食谱中,我们实现了一个检测捏合缩放旋转和 fling/swipe 手势的函数。它可以作为识别您自定义手势的起点。
准备工作
本食谱依赖于本章中的在 Android 上处理多点触控事件食谱来处理多点触控输入。
如何操作...
-
我们将运动解码任务分解为各个层次。低级代码处理操作系统生成的触摸事件。收集到的触摸点数据由中级代码中的一组例程处理,我们将在本食谱中介绍这些内容。最后,所有解码的手势都通过简单的
iGestureResponder
接口报告给用户的高级代码:class iGestureResponder { public:
-
Event_UpdateGesture()
方法提供了直接访问接触点当前状态的功能。在讨论了iGestureResponder
之后,紧接着介绍了sMotionData
结构。1_MultitouchInput
示例重写了这个方法来渲染触摸点:virtual void Event_UpdateGesture( const sMotionData& Data ) {}
-
Event_PointerChanged()
和Event_PointerMoved()
方法被调用,以指示单个触摸的变化:virtual void Event_PointerChanged(int PtrID,const vec2& Pnt, bool Pressed) {} virtual void Event_PointerMoved(int PtrID, const vec2&const vec2& Pnt){}
-
解码的手势信息被发送到
iGestureResponder
实例。当 fling/swipe 事件结束时,会调用Event_Fling()
方法:virtual void Event_Fling( const sTouchPoint& Down,const sTouchPoint& Up ) {}
-
使用
Up
和Down
点的时间戳,响应者可以估计手指移动的速度并决定手势是否成功。当手指在屏幕上拖动时,会调用Event_Drag()
方法:virtual void Event_Drag( const sTouchPoint& Down,const sTouchPoint& Current ) {}
-
捏合缩放事件通过三种方法处理。当手势开始时调用
Event_PinchStart()
方法,手势结束时调用Event_PinchStop()
,每次更新两个触摸点时调用Event_Pinch()
方法:virtual void Event_PinchStart( const sTouchPoint& Initial1,const sTouchPoint& Initial2 ) {} virtual void Event_Pinch( const sTouchPoint& Initial1,const sTouchPoint& Initial2,const sTouchPoint& Current1,const sTouchPoint& Current2 ) {} virtual void Event_PinchStop( const sTouchPoint& Initial1,const sTouchPoint& Initial2,const sTouchPoint& Current1,const sTouchPoint& Current2 ) {}; };
-
让我们转到中级例程来解码手势。首先,声明一个
iGestureResponder
的实例,稍后使用:iGestureResponder* g_Responder;
-
我们引入了
sMotionData
结构,它描述了当前的手势状态。使用Get*
函数访问单个触摸点的特征。AddTouchPoint()
函数确保不会添加具有重复 ID 的点:struct sMotionData { sMotionData(): FTouchPoints() {}; void Clear() { FTouchPoints.clear(); }; size_t GetNumTouchPoints() const { returnFTouchPoints.size(); } const sTouchPoint& GetTouchPoint( size_t Idx ) const {return FTouchPoints[Idx]; } vec2 GetTouchPointPos(size_t i) const { returnFTouchPoints[i].FPoint; } int GetTouchPointID(size_t i) const { returnFTouchPoints[i].FID; } void AddTouchPoint( const sTouchPoint& TouchPoint ) { for ( size_t i = 0; i != FTouchPoints.size(); i++ ) if ( FTouchPoints[i].FID == TouchPoint.FID ) { FTouchPoints[i] = TouchPoint; return; } FTouchPoints.push_back( TouchPoint ); } private: std::vector<sTouchPoint> FTouchPoints; };
-
手势由其触摸点的当前状态和先前触摸点状态的环形缓冲区描述。为了检测手势,我们创建了一个临时的状态机。两个布尔变量指示我们是否真的有手势以及手势是否正在进行中。对于每种类型的手势,也存储有效性标志:
sMotionData FMotionData; RingBuffer<sMotionData> FPrevMotionData(5); bool FMotionDataValid = false; bool FMoving = false; bool FFlingWasValid = false; bool FPinchZoomValid = false; bool FPinchZoomWasValid = false;
-
单指手势,如抛掷、拖拽或轻触,由当前点和初始点描述。捏合缩放是双指手势,其状态由两个初始点和两个当前点确定。中心点坐标是初始点和当前点坐标的平均值:
sTouchPoint FInitialPoint( 0, LVector2(), MOTION_MOVE, 0.0 ); sTouchPoint FCurrentPoint( 0, LVector2(), MOTION_MOVE, 0.0 ); sTouchPoint FInitialPoint1, FInitialPoint2; sTouchPoint FCurrentPoint1, FCurrentPoint2; float FZoomFactor = 1.0f; float FInitialDistance = 1.0f; LVector2 FInitialCenter, FCurrentCenter;
-
为了忽略意外的屏幕触摸,我们引入了一个灵敏度阈值,这是手指必须移动的最小屏幕空间百分比,以便检测到抛掷手势:
float FlingStartSensitivity = 0.2f;
-
如果手指最终位置相对于初始位置移动小于以下值,那么抛掷手势将被完全忽略:
float FlingThresholdSensitivity = 0.1f;
-
RingBuffer
数据结构是使用一个简单的动态数组实现的。完整的源代码在RingBuffer.h
文件中:template <typename T> class RingBuffer { public: explicit RingBuffer(size_t Num): FBuffer(Num) { clear(); } inline void clear() { FCount = FHead = 0; } inline void push_back( const T& Value ) { if ( FCount < FBuffer.size() ) FCount++; FBuffer[ FHead++ ] = Value; if ( FHead == FBuffer.size() ) FHead = 0; }
-
唯一的特殊方法是相对于
FHead
的先前状态的访问器:inline T* prev(size_t i) { return (i >= FCount) ? NULL: &FBuffer[AdjustIndex(i)]; } private: std::vector<T> FBuffer;
-
当前元素和项目总数:
size_t FHead; size_t FCount;
-
负值时的带环绕的除法余数:
inline int ModInt( int a, int b ) { int r = a % b; return ( r < 0 ) ? r+b : r; }
-
最后一个例程计算前一个元素索引:
inline size_t AdjustIndex( size_t i ) const { return (size_t)ModInt( (int)FHead - (int)i - 1,(int)FBuffer.size() ); } };
-
为了解码手势,我们仔细处理每一个触摸事件。在开始时我们重置触摸点集合,在触摸结束时我们检查手势是否完成:
void GestureHandler_SendMotion( int ContactID, eMotionFlagFlag,LVector2 Pos, bool Pressed ) { if ( ContactID == MOTION_START ) { FMotionDataValid = false; FMotionData.Clear(); return; } if ( ContactID == MOTION_END ) { FMotionDataValid = true; UpdateGesture(); g_Responder->Event_UpdateGesture( FMotionData ); if ( sMotionData* P = FPrevMotionData.prev(0) ) { if ( P->GetNumTouchPoints() !=FMotionData.GetNumTouchPoints() )FPrevMotionData.push_back( FMotionData ); } else { FPrevMotionData.push_back( FMotionData ); } return; }
-
如果我们仍在移动,那么修改当前点的信息:
if ( Pressed ) FMotionData.AddTouchPoint( sTouchPoint( ContactID, Pos,MOTION_DOWN, Env_GetSeconds() ) );
-
根据运动标志,我们通知响应者关于个别触摸的信息:
switch ( Flag ) { case MOTION_MOVE: g_Responder->Event_PointerMoved( ContactID, Pos ); break; case MOTION_UP: case MOTION_DOWN: g_Responder->Event_PointerChanged( ContactID, Pos,Flag == MOTION_DOWN ); break; } }
-
UpdateGesture()
函数负责所有的检测工作。它会检查当前的手势状态,并在有手势进行中的时候调用g_Responder
对象的方法:void UpdateGesture() { const sTouchPoint& Pt1 = FInitialPoint; const sTouchPoint& Pt2 = FCurrentPoint; g_Responder->Event_UpdateGesture( FMotionData );
-
拖拽和捏合手势通过
IsDraggingValid()
和IsPinchZoomValid()
方法进行检查,这些方法稍后会进行描述。如果手指移动超过特定距离,我们会响应单点拖拽:if ( IsDraggingValid() ) { if ( GetPositionDelta().Length() >FlingThresholdSensitivity ) { g_Responder->Event_Drag( Pt1, Pt2 ); FFlingWasValid = true; } } else if ( FFlingWasValid ) { if ( GetPositionDelta().Length() >FlingStartSensitivity ) g_Responder->Event_Fling( Pt1, Pt2 ); else g_Responder->Event_Drag( Pt1, Pt2 ); FFlingWasValid = false; } if ( IsPinchZoomValid() ) { if ( FPinchZoomWasValid ) g_Responder->Event_Pinch( FInitialPoint1,FInitialPoint2, FCurrentPoint1,FCurrentPoint2 ); else g_Responder->Event_PinchStart( FInitialPoint1,FInitialPoint2 ); FPinchZoomWasValid = true; } else if ( FPinchZoomWasValid ) { FPinchZoomWasValid = false; g_Responder->Event_PinchStop( FInitialPoint1,FInitialPoint2, FCurrentPoint1, FCurrentPoint2 ); } }
-
之前描述的
UpdateGesture()
函数使用了以下辅助函数:static vec2 GetPositionDelta() { return FCurrentPoint.FPoint - FInitialPoint.FPoint; }
-
拖拽或抛掷动作应该用一根手指完成。为了区分拖拽和抛掷,我们使用
IsDraggingValid()
函数:static bool IsDraggingValid() { if ( FMotionDataValid && FMotionData.GetNumTouchPoints() == 1&& FMotionData.GetTouchPointID( 0 ) == 0 ) { if ( !FMoving ) { FMoving = true; FInitialPoint = FMotionData.GetTouchPoint( 0 ); return false; } FCurrentPoint = FMotionData.GetTouchPoint( 0 ); } else { FMoving = false; } return FMoving; }
-
为了检查用户是否正在执行捏合缩放手势,我们调用
IsPinchZoomValid()
函数。我们获取触摸点并计算它们之间的距离。如果我们已经在执行捏合缩放手势,我们更新当前点。否则,我们存储初始点并计算中心:static bool IsPinchZoomValid() { if (FMotionDataValid && FMotionData.GetNumTouchPoints() == 2 ) { const sTouchPoint& Pt1 = FMotionData.GetTouchPoint(0); const sTouchPoint& Pt2 = FMotionData.GetTouchPoint(1); const LVector2& Pos1(FMotionData.GetTouchPointPos(0)); const LVector2& Pos2(FMotionData.GetTouchPointPos(1)); float NewDistance = (Pos1 - Pos2).Length(); if ( FPinchZoomValid ) { FZoomFactor = NewDistance / FInitialDistance; FCurrentPoint1 = Pt1; FCurrentPoint2 = Pt2; FCurrentCenter = ( Pos1 + Pos2 ) * 0.5f; } else { FInitialDistance = NewDistance; FPinchZoomValid = true; FZoomFactor = 1.0f; FInitialPoint1 = Pt1; FInitialPoint2 = Pt2; FInitialCenter = ( Pos1 + Pos2 ) * 0.5f; return false; } } else { FPinchZoomValid = false; FZoomFactor = 1.0f; } return FPinchZoomValid; }
它的工作原理...
g_Responder
实例接收所有关于解码手势的数据。
实现屏幕上的游戏手柄
是时候利用多点触控功能,在 Android 设备触摸屏上模拟类似游戏控制台界面了。
准备就绪
在继续这个食谱之前,先学习如何处理来自在 Android 上处理多点触控事件和在 Windows 上处理多点触控事件的食谱的多点触控输入。
如何操作...
我们实现了一个自定义的多点触控事件处理器,它跟踪所有的触控点。游戏手柄被渲染成左侧的全屏位图。当用户触摸屏幕时,我们使用触摸坐标从图右侧的遮罩中获取像素颜色。然后,我们找到与颜色对应的内部按钮并改变其Pressed
状态。下图展示了游戏手柄的可视表示和颜色遮罩:
-
我们虚拟游戏手柄的单个按钮由其在遮罩中的颜色和在按钮表中的索引确定:
struct sBitmapButton { vec4 FColour; int FIndex; };
-
虚拟模拟杆支持两个方向,由其半径、遮罩颜色和位置确定:
struct sBitmapAxis { float FRadius; vec2 FPosition; int FAxis1, FAxis2; vec4 Fcolour; };
-
ScreenJoystick
类包含了所有按钮和轴的描述:class ScreenJoystick { std::vector<sBitmapButton> FButtonDesc; std::vector<sBitmapAxis> FAxisDesc;
-
每个轴的值和每个按钮的
Pressed
标志存储在两个数组中:std::vector<float> FAxisValue; std::vector<bool> FKeyValue;
-
这个类还需要遮罩位图数据指针:
unsigned char* FMaskBitmap;
-
FPushed*
数组告诉我们当前哪些按钮和轴被激活了:sBitmapButton* FPushedButtons[MAX_TOUCH_CONTACTS]; sBitmapAxis* FPushedAxis[MAX_TOUCH_CONTACTS];
-
构造函数和析构函数本质上是空的:
ScreenJoystick(): FMaskBitmap( NULL ) {} virtual ~ScreenJoystick() {}
-
InitKeys()
方法在游戏手柄构造完成后分配状态数组:void InitKeys() { FKeyValue.resize( FButtonDesc.size() ); if ( FKeyValue.size() > 0 ) { for (size_t j = 0 ; j < FKeyValue.size() ; j++ ) FKeyValue[j] = false; } FAxisValue.resize( FAxisDesc.size() * 2 ); if ( FAxisValue.size() > 0 ) { memset( &FAxisValue[0], 0, FAxisValue.size() *sizeof( float ) ); } Restart(); }
-
Restart()
方法清除被按下按钮的状态:void Restart() { memset( &FPushedAxis[0], 0, sizeof(sBitmapAxis*) *MAX_TOUCH_CONTACTS ); memset( &FPushedButtons[0], 0, sizeof(sBitmapButton*) *MAX_TOUCH_CONTACTS ); }
-
内部状态由私有的
SetAxisValue()
和SetKeyState()
方法改变:void SetKeyState( int KeyIdx, bool Pressed ) { if ( KeyIdx < 0 || KeyIdx >= ( int )FKeyValue.size() ) { return; } FKeyValue[KeyIdx] = Pressed; } void SetAxisValue( int AxisIdx, float Val ) { if ( ( AxisIdx < 0 ) ||AxisIdx >= (int)FAxisValue.size() ) { return; } FAxisValue[AxisIdx] = Val; }
-
IsPressed()
和GetAxisValue()
方法可以读取一个键或轴的状态:bool IsPressed( int KeyIdx ) const { return ( KeyIdx < 0 ||KeyIdx >= ( int )FKeyValue.size() ) ?false : FKeyValue[KeyIdx]; } float GetAxisValue( int AxisIdx ) const { return ( ( AxisIdx < 0 ) ||AxisIdx >= ( int )FAxisValue.size() ) ?0.0f : FAxisValue[AxisIdx]; }
-
下面的内部方法通过给定的颜色查找按钮和轴:
sBitmapButton* GetButtonForColour( const vec4& Colour )const { for ( size_t k = 0 ; k < FButtonDesc.size(); k++ ) { float Distance = (FButtonDesc[k]->FColour –Colour).Length(); if ( Distance < 0.1f ) return FButtonDesc[k]; } return NULL; } sBitmapAxis* GetAxisForColour( const vec4& Colour ) const { for ( size_t k = 0 ; k < FAxisDesc.size(); k++ ) { float Distance = (FButtonDesc[k]->FColour –Colour).Length(); if ( Distance < 0.1f ) return FAxisDesc[k]; } return NULL; }
-
每个轴的两个值作为从中心点的位移读取:
void ReadAxis( sBitmapAxis* Axis, const vec2& Pos ) { if ( !Axis ) { return; }
-
根据中心点和触摸点读取轴值:
float v1 = ( (Axis->FPosition - Pos).x/Axis->FRadius); float v2 = (-(Axis->FPosition - Pos).y/Axis->FRadius); this->SetAxisValue( Axis->FAxis1, v1 ); this->SetAxisValue( Axis->FAxis2, v2 ); } vec4 GetColourAtPoint( const vec2& Pt ) const { if ( !FMaskBitmap ) { return vec4( -1 ); } int x = (int)(Pt.x * 512.0f); int y = (int)(Pt.y * 512.0f); int Ofs = (y * 512 + x) * 3; float r = (float)FMaskBitmap[Ofs + 0] / 255.0f; float g = (float)FMaskBitmap[Ofs + 1] / 255.0f; float b = (float)FMaskBitmap[Ofs + 2] / 255.0f; return vec4( b, g, r, 0.0f ); }
-
主例程是
HandleTouch()
方法:void HandleTouch( int ContactID, const vec2& Pos, bool Pressed, eMotionFlag Flag ) {
-
如果触摸刚刚开始,我们重置每个按钮和轴的值:
if ( ContactID == MOTION_START ) { for ( size_t i = 0; i != MAX_TOUCH_CONTACTS; i++ ) { if ( FPushedButtons[i] ) { this->SetKeyState( FPushedButtons[i]->FIndex, false ); FPushedButtons[i] = NULL; } if ( FPushedAxis[i] ) { this->SetAxisValue( FPushedAxis[i]->FAxis1, 0.0f ); this->SetAxisValue( FPushedAxis[i]->FAxis2, 0.0f ); FPushedAxis[i] = NULL; } } return; } if ( ContactID == MOTION_END ) { return; } if ( ContactID < 0 || ContactID >= MAX_TOUCH_CONTACTS ) { return; }
-
如果指针正在移动,我们查找相应的按钮或轴:
if ( Flag == MOTION_DOWN || Flag == MOTION_MOVE ) { vec4 Colour = GetColourAtPoint( Pos ); sBitmapButton* Button = GetButtonForColour( Colour ); sBitmapAxis* Axis = GetAxisForColour( Colour );
-
对于我们找到的每个按钮,将按下状态设置为真:
if ( Button && Pressed ) { int Idx = Button->FIndex; this->SetKeyState( Idx, true ); FPushedButtons[ContactID] = Button; }
-
对于找到的每个轴,我们读取其值:
if ( Axis && Pressed ) { this->ReadAxis( Axis, Pos ); FPushedAxis[ContactID] = Axis; } } }
工作原理...
我们声明了一个全局变量,它保存了游戏手柄的状态:
ScreenJoystick g_Joystick;
在OnStart()
方法中,我们添加两个轴和一个按钮:
float A_Y = 414.0f / 512.0f;
sBitmapAxis B_Left;
B_Left.FAxis1 = 0;
B_Left.FAxis2 = 1;
B_Left.FPosition = vec2( 55.0f / 512.f, A_Y );
B_Left.FRadius = 40.0f / 512.0f;
B_Left.FColor = vec4( 0.75f, 0.75f, 0.75f, 0.0f );
sBitmapButton B_Fire;
B_Fire.FIndex = ID_BUTTON_THRUST;
B_Fire.FColor = vec4( 0 );
g_Joystick.FAxisDesc.push_back( B_Left );
g_Joystick.FButtonDesc.push_back( B_Fire );
然后,我们初始化游戏手柄并重置其状态:
g_Joystick.InitKeys();
g_Joystick.Restart();
在代码稍后部分,我们可以使用g_Joystick.GetAxisValue
的结果来获取当前的轴值,以及使用g_Joystick.IsPressed
来查看按键是否被按下。
使用 FreeType 进行文本渲染
界面可能避免渲染文本信息。然而,大多数应用程序必须在屏幕上显示一些文本。现在是详细考虑带字符间距和字形缓存的FreeType文本渲染的时候了。这是本书最长的食谱,但我们确实不希望错过 FreeType 使用中的细节和微妙之处。
准备就绪
现在是时候将本书第二章《移植通用库》中关于 FreeType 编译的实际应用提上日程了。我们从第一章建立构建环境中描述的空应用程序模板开始。以下代码支持多种字体、自动字距调整和字形缓存。
在排版中,字距调整(较少见的是嵌槽)是调整比例字体中字符间间距的过程,通常是为了达到视觉上令人满意的效果。
致谢:en.wikipedia.org/wiki/Kerning
字形缓存是 FreeType 库的一个特性,它通过使用字形图像和字符图来减少内存使用。你可以阅读关于它的内容在www.freetype.org/freetype2/docs/reference/ft2-cache_subsystem.html
。
如何操作...
在这里我们开发了TextRenderer
类,它保存了 FreeType 库的所有状态。我们将文本渲染封装在一个类中以支持此类多个实例,并确保线程安全。
-
所需的 FreeType 库初始化包括库实例、字形缓存、字符图缓存和图像缓存。我们首先声明内部的 FreeType 对象:
class TextRenderer { // Local instance of the library (for thread-safeexecution) FT_Library FLibrary; // Cache manager FTC_Manager FManager; // Glyph cache FTC_ImageCache FImageCache; // Character map cache FTC_CMapCache FCMapCache;
-
然后声明已加载字体的列表:
// List of available font faces std::vector<std::string> FFontFaces; // Handle for the current font face FT_Face FFace; // List of loaded font files to prevent multiple filereads std::map<std::string, void*> FAllocatedFonts; // List of initialized font face handles std::map<std::string, FT_Face> FFontFaceHandles;
-
FMaskMode
开关用于选择不透明渲染和 alpha 遮罩创建。它稍后在字形渲染代码中提到:bool FMaskMode;
-
初始化例程创建 FreeType 库实例并初始化字形和图像缓存:
void InitFreeType() { LoadFT(); FT_Init_FreeTypePTR( &FLibrary ); FTC_Manager_NewPTR(FLibrary,0,0,0, FreeType_Face_Requester, this, &FManager); FTC_ImageCache_NewPTR( FManager, &FImageCache ); FTC_CMapCache_NewPTR( FManager, &FCMapCache ); }
与往常一样,我们提供了尽可能简短的代码。完整的代码应该检查
FTC_*
函数返回的非零代码。LoadFT()
函数初始化 FreeType 库的函数指针。在本书的代码中,为了允许在 Windows 上动态加载库,我们为所有 FreeType 函数使用了PTR
后缀。如果你只关心 Android 开发,可以省略PTR
后缀。 -
反初始化例程清除所有内部数据并销毁 FreeType 对象:
void StopFreeType() { FreeString(); auto p = FAllocatedFonts.begin(); for ( ; p!= FAllocatedFonts.end() ; p++ ) delete[] ( char* )( p->second ); FFontFaces.clear(); FTC_Manager_DonePTR( FManager ); FT_Done_FreeTypePTR( FLibrary ); }
-
FreeString()
例程清除内部 FreeType 字形缓存:void FreeString() { for ( size_t i = 0 ; i < FString.size() ; i++ ) if ( FString[i].FCacheNode != NULL ) FTC_Node_UnrefPTR(FString[i].FCacheNode,FManager); FString.clear(); }
-
FString
包含正在渲染的字符串的所有字符。初始化和反初始化函数分别在构造函数和析构函数中调用:TextRenderer(): FLibrary( NULL ), FManager( NULL ),FImageCache( NULL ), FCMapCache( NULL ) { InitFreeType(); FMaskMode = false; } virtual ~clTextRenderer() { StopFreeType(); }
-
为了利用TrueType字体并渲染字形,我们需要创建一组简单的管理例程来加载字体文件。第一个是
LoadFontFile()
函数,它加载字体文件,将其内容存储在列表中,并返回错误代码:FT_ErrorLoadFontFile( const std::string& File ) { if ( FAllocatedFonts.count( File ) > 0 ) { return 0; } char* Data = NULL; int DataSize; ReadFileData( File.c_str(), &Data, DataSize ); FT_Face TheFace;
-
我们总是使用第 0 个面,这是加载文件中的第一个:
FT_Error Result = FT_New_Memory_FacePTR(FLibrary,(FT_Byte*)Data, (FT_Long)DataSize, 0, &TheFace );
-
检查是否成功并将字体存储在已加载字体面的数组中:
if ( Result == 0 ) { FFontFaceHandles[File] = TheFace; FAllocatedFonts[File] = ( void* )Data; FFontFaces.push_back( File ); } return Result; }
ReadFileData()
函数加载File
的内容。鼓励您实现此功能或查看随附的源代码,其中通过我们的虚拟文件系统完成此操作。 -
静态函数
FreeType_Face_Requester()
缓存对字体面的访问,并允许我们重用已加载的字体。它在 FreeType 库头文件中定义:FT_Error FreeType_Face_Requester( FTC_FaceID FaceID,FT_Library Library, FT_Pointer RequestData, FT_Face* Face ) { #ifdef _WIN64 long long int Idx = (long long int)FaceID; int FaceIdx = (int)(Idx & 0xFF); #else int FaceIdx = reinterpret_cast< int >(FaceID); #endif if ( FaceIdx < 0 ) { return 1; } TextRenderer* Renderer = ( TextRenderer* )RequestData; std::string File = Renderer ->FFontFaces[FaceIdx]; FT_Error Result = Renderer ->LoadFontFile( File ); *Face = (Result == 0) ? Renderer->FFontFaceHandles[File] : NULL; return Result; }
FreeType 库允许
RequestData
参数,我们通过指针传递TextRenderer
的实例。在FreeType_Face_Requester()
代码中的#ifdef
是必要的,以便在 64 位 Windows 版本上运行。Android OS 是 32 位的,允许将void*
隐式地转换为int
。 -
GetSizedFace
函数为已加载的面设置字体大小:FT_Face GetSizedFace( int FontID, int Height ) { FTC_ScalerRec Scaler; Scaler.face_id = IntToID(FontID); Scaler.height = Height; Scaler.width = 0; Scaler.pixel = 1; FT_Size SizedFont; if ( !FTC_Manager_LookupSizePTR(FManager, &Scaler,&SizedFont) ) return NULL; if ( FT_Activate_SizePTR( SizedFont ) != 0 ) { returnNULL; } return SizedFont->face; }
-
然后,我们定义内部的
sFTChar
结构体,它保存有关单个字符的信息:struct sFTChar { // UCS2 character, suitable for FreeType FT_UInt FChar; // Internal character index FT_UInt FIndex; // Handle for the rendered glyph FT_Glyph FGlyph; // Fixed-point character advance and character size FT_F26Dot6 FAdvance, FWidth; // Cache node for this glyph FTC_Node FCacheNode; // Default parameters sFTChar(): FChar(0), FIndex((FT_UInt)(-1)), FGlyph(NULL),FAdvance(0), FWidth(0), FCacheNode( NULL ) { } };
-
我们渲染的文本采用 UTF-8 编码,必须将其转换为 UCS-2 多字节表示。最简单的 UTF-8 解码器读取输入字符串并将其字符输出到
FString
向量中:bool DecodeUTF8( const char* InStr ) { FIndex = 0; FBuffer = InStr; FLength = ( int )strlen( InStr ); FString.clear(); int R = DecodeNextUTF8Char(); while ( ( R != UTF8_LINE_END ) && ( R != UTF8_DECODE_ERROR ) ) { sFTChar Ch; Ch.FChar = R; FString.push_back( Ch ); R = DecodeNextUTF8Char(); } return ( R != UTF8_DECODE_ERROR ); }
-
解码器使用以下函数来读取单个字符编码:
int DecodeNextUTF8Char() { // the first byte of the character and the result int c, r; if ( FIndex >= FLength ) return FIndex == FLength ?UTF8_LINE_END : UTF8_DECODE_ERROR; c = NextUTF8(); if ( ( c & 0x80 ) == 0 ) { return c; } if ( ( c & 0xE0 ) == 0xC0 ) { int c1 = ContUTF8(); if ( c1 < 0 ) { return UTF8_DECODE_ERROR; } r = ( ( c & 0x1F ) << 6 ) | c1; return r >= 128 ? r : UTF8_DECODE_ERROR; } if ( ( c & 0xF0 ) == 0xE0 ) { int c1 = ContUTF8(), c2 = ContUTF8(); if ( c1 < 0 || c2 < 0 ) { return UTF8_DECODE_ERROR; } r = ( ( c & 0x0F ) << 12 ) | ( c1 << 6 ) | c2; return r>=2048&&(r<55296||r>57343)?r:UTF8_DECODE_ERROR; } if ( ( c & 0xF8 ) == 0xF0 ) { int c1 = ContUTF8(), c2 = ContUTF8(), c3 = ContUTF8(); if (c1 < 0||c2 < 0||c3< 0) { return UTF8_DECODE_ERROR; } r = (( c & 0x0F ) << 18) | (c1 << 12) | (c2 << 6) | c3; return r>=65536 && r<=1114111 ? r: UTF8_DECODE_ERROR; } return UTF8_DECODE_ERROR; }
注意
DecodeNextUTF8Char()
的源代码取自 Linderdaum Engine,位于www.linderdaum.com
。 -
NextUTF8()
和ContUTF8()
内联函数在解码缓冲区旁边声明:static const int UTF8_LINE_END = 0; static const int UTF8_DECODE_ERROR = -1;
-
包含当前字符串的缓冲区:
std::vector<sFTChar> FString;
-
当前字符索引和源缓冲区长度:
int FIndex, FLength;
-
源缓冲区的原始指针和当前字节:
const char* FBuffer; int FByte;
-
如果没有剩余的字节,则获取下一个字节或
UTF8_LINE_END
:inline int NextUTF8() { return ( FIndex >= FLength ) ? UTF8_LINE_END : ( FBuffer[FIndex++] & 0xFF ); }
-
获取下一个延续字节的低六位,如果它不是延续字节,则返回
UTF8_DECODE_ERROR
:inline int ContUTF8() { int c = NextUTF8(); return ( ( c & 0xC0 ) == 0x80 ) ? ( c & 0x3F ) : UTF8_DECODE_ERROR; }
-
到目前为止,我们已经有了字体加载函数和一个 UTF-8 解码器。现在是处理实际渲染的时候了。我们首先想要做的是计算屏幕像素中的字符串大小,这由
CalculateLineParameters
函数执行:void CalculateLineParameters(int* Width, int* MinY, int* MaxY, int* BaseLine ) const {
-
我们使用两个变量来查找最小和最大垂直位置:
int StrMinY = -1000, StrMaxY = -1000; if ( FString.empty() ) StrMinY = StrMaxY = 0;
-
另一个变量存储字符串的水平大小:
int SizeX = 0;
-
我们遍历
FString
数组,并使用sFTChar::FGlyph
字段来获取字符的垂直大小。同时,我们将FAdvance
字段加到SizeX
上,以考虑字距调整和水平字符大小:for ( size_t i = 0 ; i != FString.size(); i++ ) { if ( FString[i].FGlyph == NULL ) { continue; } auto Glyph = ( FT_BitmapGlyph )FString[i].FGlyph; SizeX += FString[i].FAdvance; int Y = Glyph->top; int H = Glyph->bitmap.rows; if ( Y > StrMinY ) { StrMinY = Y; } if ( H - Y > StrMaxY ) { StrMaxY = H - Y; } } if ( Width ) { *Width = ( SizeX >> 6 ); } if ( BaseLine ) { *BaseLine = StrMaxY; } if ( MinY ) { *MinY = StrMinY; } if ( MaxY ) { *MaxY = StrMaxY; } }
-
我们使用前面的代码将 UTF-8 字符串渲染到新分配的位图中:
clPtr<Bitmap> RenderTextWithFont( const std::string& Str, int FontID, int FontHeight, unsigned int Color, bool LeftToRight ) {
-
解码 UTF-8 输入字符串并计算每个字符的位置:
if ( !LoadTextStringWithFont(Str, FontID, FontHeight) ) { return NULL; }
-
计算水平和垂直字符串尺寸并为输出位图分配空间:
int W, Y, MinY, MaxY; CalculateLineParameters( &W, &MinY, &MaxY, &Y ); clPtr<Bitmap> Result = new Bitmap( W, MaxY + MinY);
-
将所有字形渲染到位图中。如果文本是从右到左的,则从位图的另一侧开始:
RenderLineOnBitmap( TextString, FontID, FontHeight, LeftToRight ? 0 : W - 1, MinY, Color, LeftToRight,Result ); return Result; }
-
LoadStringWithFont()
例程负责计算字符串S
中每个字符的水平位置:bool LoadStringWithFont(const std::string& S, int ID, intHeight ) { if ( ID < 0 ) { return false; }
-
获取所需的字体面:
FFace = GetSizedFace( ID, Height ); if ( FFace == NULL ) { return false; } bool UseKerning = FT_HAS_KERNING( Face );
-
解码输入的 UTF-8 字符串并计算字符大小,检查
FString
中的每个元素:DecodeUTF8( S.c_str() ); for ( size_t i = 0, count = FString.size(); i != count;i++ ) { sFTChar& Char = FString[i]; FT_UInt ch = Char.FChar; Char.FIndex = ( ch != '\r' && ch != '\n' ) ?GetCharIndex(ID, ch) : -1;
-
加载与字符对应的字形:
Char.FGlyph = ( Char.FIndex != -1 ) ?GetGlyph( ID, Height, ch,FT_LOAD_RENDER, &Char.FCacheNode ) : NULL; if ( !Char.FGlyph || Char.FIndex == -1 ) continue;
-
计算此字形的水平偏移量:
SetAdvance( Char );
-
计算除第一个字符外的每个字符的间距:
if (i > 0 && UseKerning) Kern(FString[i - 1], Char); } return true; }
-
LoadStringWithFont()
函数使用辅助例程Kern()
和SetAdvance()
来计算两个连续字符之间的偏移量:void SetAdvance( sFTChar& Char ) { Char.FAdvance = Char.FWidth = 0; if ( !Char.FGlyph ) { return; }
-
将值从 26.6 固定小数格式转换:
Char.FAdvance = Char.FGlyph->advance.x >> 10; FT_BBox bbox; FT_Glyph_Get_CBoxPTR( Char.FGlyph,FT_GLYPH_BBOX_GRIDFIT, &bbox ); Char.FWidth = bbox.xMax; if ( Char.FWidth == 0 && Char.FAdvance != 0 ) { Char.FWidth = Char.FAdvance; } } void Kern( sFTChar& Left, const sFTChar& Right ) { if ( Left.FIndex == -1 || Right.FIndex == -1 ) { return; } FT_Vector Delta; FT_Get_KerningPTR( FFace, Left.FIndex, Right.FIndex,FT_KERNING_DEFAULT, &Delta ); Left.FAdvance += Delta.x; }
-
最后,一旦我们有了每个字符的位置,我们将各个字形渲染到位图上:
void RenderLineOnBitmap( const std::string& S,int FontID, int FontHeight, int StartX, int Y,unsigned int C, bool LeftToRight, const clPtr<Bitmap>&Out ) { LoadStringWithFont( S, FontID, FontHeight ); int x = StartX << 6; for ( size_t j = 0 ; j != FString.size(); j++ ) { if ( FString[j].FGlyph != 0 ) { auto Glyph = (FT_BitmapGlyph) FString[j].FGlyph; int in_x = (x>>6); in_x += (LeftToRight ? 1 : -1) * BmpGlyph->left; if ( !LeftToRight ) { in_x += BmpGlyph->bitmap.width; in_x = StartX + ( StartX - in_x ); } DrawGlyph( Out, &BmpGlyph->bitmap, in_x, Y -BmpGlyph->top, Color ); } x += FString[j].FAdvance; } }
RenderLineOnBitmap()
中的代码相当直接。唯一微妙之处在于位运算移位操作,它将内部的 FreeType 26.6 位固定小数格式转换为标准整数。首先,我们将StartX
左移以获得 FreeType 的坐标,对于每个像素,我们将x
右移以获得屏幕位置。注意事项
FreeType 在内部使用 26.6 固定小数格式来定义分数像素坐标。
-
DrawGlyph()
例程根据渲染模式,从字形复制原始像素,或者将源像素与字形的像素相乘:void DrawGlyph (const clPtr<Bitmap>& Out, FT_Bitmap* Bmp,int X0, int Y0, unsigned int Color ) { unsigned char* Data = Out->FBitmapData; int W = Out->FWidth; int Width = W - X0; if ( Width > Bmp->width ) { Width = Bmp->width; } for ( int Y = Y0 ; Y < Y0 + Bmp->rows ; ++Y ) { unsigned char* Src = Bmp->buffer + (Y-Y0)*Bmp->pitch; if ( FMaskMode ) { for ( int X = X0 + 0 ; X < X0 + Width ; X++ ) { int Int = *Src++; unsigned char Col = (Int & 0xFF); for(int j = 0 ; j < 4 ; j++) Data[(Y * W + X) * 4 + j]= Col; } } else { for ( int X = X0 + 0 ; X < X0 + Width ; X++ ) { unsigned int Col = MultColor(Color, *Src++); if ( Int > 0 ) { ((unsigned int*)Data)[Y * W + X] = Col; } } } } }
-
辅助
MultColor()
函数将整数编码颜色的每个分量与Mult
因子相乘:unsigned int MultColor( unsigned int C, unsigned int Mult ) { return (Mult << 24) | C; }
工作原理...
渲染 UTF-8 字符串所需的最小代码涵盖了创建TextRenderer
实例、字体加载以及使用加载的字体进行实际文本渲染:
TextRenderer txt;
int fnt = txt.GetFontHandle("some_font.ttf");
以葡萄牙语单词direção(意为方向)为例进行渲染:
char text[] = { 'D','i','r','e',0xC3,0xA7,0xC3,0xA3,'o',0 };
auto bmp =
txt.RenderTextWithFont(text, fnt, 24, 0xFFFFFFFF, true);
结果是bmp
变量,其中包含渲染的文本,如下面的屏幕截图所示:
还有更多…
这是迄今为止最长的食谱,但仍然省略了一些重要细节。如果你每帧渲染的文本量足够大,预渲染一些字符串并避免重新创建图像是有意义的。
游戏内字符串的本地化
移动应用程序在各种设备上使用,而且这些设备经常配置为使用非英语的语言。本食谱展示了如何在应用程序 UI 中显示文本消息时实现国际化。
准备就绪
回顾第四章,组织虚拟文件系统,关于使用我们实现的虚拟文件系统抽象进行只读文件访问。
如何操作...
-
对于我们想要支持的每种语言,我们需要准备一组翻译后的字符串。我们将这些字符串存储在一个文件中。对于英文-俄文语言对,一个例子就是
Localizer-ru.txt
文件:Hello~Привет Good Bye~Пока
-
~
字符用作原始短语与其翻译之间的分隔符。原始短语可以用作键,并与它的翻译一起存储在一个全局的std::map
容器中:std::map<std::string, std::string> g_Translations; … g_Translations["Original phrase"] = "Translation"
-
假设我们有一个全局变量中的地区名称:
std::string g_LocaleName;
-
我们只需要实现使用
g_Translations
映射的LocalizeString()
函数:std::string LocalizeString( const std::string& Str ) const { auto i = g_Translations.find( Str ); return (i != g_Translations.end()) ? i->second : Str; }
-
LoadLocale()
例程使用全局g_LocaleName
变量,并加载所需的翻译表,跳过不含~
字符的行:void LoadLocale() { g_Translations.clear(); const std::string FileName( g_LocalePath + "/Localizer-"+ g_LocaleName + ".txt" ); if ( !g_FS->FileExists( FileName ) ) { return; } auto Stream = g_FS->CreateReader( FileName ); while ( !Stream->Eof() ) { std::string L = Stream->ReadLine(); size_t Pos = L.find( "~" ); if ( Pos == std::string::npos ) { continue; }g_Translations[ L.substr(0, Pos) ] = L.substr(Pos + 1); } }
-
为了简单起见,我们定义了存储本地化字符串文件的目录,在另一个全局变量中:
const std::string g_LocalePath = "Localizer";
它是如何工作的...
LocalizeString()
函数接受基础语言的字符串并返回其翻译。每当我们想要渲染一些文本时,我们不会直接使用字符串字面量,因为这会严重降低我们本地化游戏的能力。相反,我们将这些字面量包装到LocalizeString()
调用中:
PrintString( LocalizeString( "Some text") );
还有很多...
要以适当的语言渲染文本,我们可以使用操作系统函数来检测其当前地区设置。在 Android 上,我们在Activity
中使用以下 Java 代码。SetLocale()
是从Activity
构造函数中调用的:
import java.util.Locale;
…
private static void SetLocale()
{
检测地区名称并将其传递给我们的本地代码:
String Lang = Locale.getDefault().getLanguage();
SetLocaleName( Lang );
}
在本地代码中,我们只是捕获了地区名称:
JNIEXPORT void JNICALL
Java_ com_packtpub_ndkcookbook_app14_App14Activity_SetLocaleName(
JNIEnv* env, jobject obj, jstring LocaleName )
{
g_LocaleName = ConvertJString( env, LocaleName );
}
在 Windows 上,事情甚至更简单。我们调用GetLocaleInfo() WinAPI
函数,并以 ISO639 格式提取当前语言名称(en.wikipedia.org/wiki/ISO_639
):
char Buf[9];
GetLocaleInfo( LOCALE_USER_DEFAULT, LOCALE_SISO639LANGNAME,Buf, sizeof(Buf) );
g_LocaleName = std::string( Buf );
第八章:编写匹配-3 游戏
在本章中,我们将涵盖:
-
处理异步多点触控输入
-
改进音频播放机制
-
关闭应用程序
-
实现主循环
-
创建多平台游戏引擎
-
编写匹配-3 游戏
-
管理形状
-
管理游戏场地逻辑
-
在游戏循环中实现用户交互
简介
在本章中,我们开始将前面章节的食谱整合在一起。以下的大部分食谱旨在改进和整合前面章节中散布的材料。
注意
本章节的示例项目实际上是 Google Play 上发布的 MultiBricks 游戏的简化版:play.google.com/store/apps/details?id=com.linderdaum.engine.multibricks
。
处理异步多点触控输入
在上一章中,我们学习了如何在 Android 上处理多点触控事件。然而,我们简单的示例有一个严重的问题。Android 的触摸事件是异步发送的,可能会干扰游戏逻辑。因此,我们需要创建一个队列,以可控的方式处理事件。
准备就绪
在继续之前,请查看第七章中的“在 Android 上处理多点触控事件”的食谱,跨平台 UI 和输入系统。
如何操作…
-
在上一章中,我们直接从异步 JNI 回调中调用触摸处理器:
Java_com_packtpub_ndkcookbook_game1_Game1Activity_SendMotion( JNIEnv * env, jobject obj, int PointerID, int x, int y, bool Pressed, int Flag) { LVector2 Pos = LVector2( (float)x / (float)g_Width, (float)y / (float)g_Height ); GestureHandler_SendMotion( PointerID, (eMotionFlag)Flag, Pos,Pressed ); }
-
这次,我们需要将所有事件存储在队列中,而不是立即处理它们。队列将持有传递给
GestureHandler_SendMotion()
的结构体中的参数:struct sSendMotionData { int ContactID; eMotionFlag Flag; LVector2 Pos; bool Pressed; };
-
队列实现依赖于
std::vector
,持有触摸事件和Mutex
,提供队列访问同步:Mutex g_MotionEventsQueueMutex; std::vector<sSendMotionData> g_MotionEventsQueue;
-
我们新的
SendMotion()
JNI 回调需要做的工作就是将触摸事件参数打包进队列:Java_com_packtpub_ndkcookbook_game1_Game1Activity_SendMotion( JNIEnv * env, jobject obj, int PointerID, int x, int y, bool Pressed, int Flag) { sSendMotionData M; M.ContactID = PointerID; M.Flag = (eMotionFlag)Flag; M.Pos = LVector2( (float)x / (float)g_Width, (float)y / (float)g_Height ); M.Pressed = Pressed; LMutex Lock( &g_MotionEventsQueueMutex ); g_MotionEventsQueue.push_back( M ); }
我们现在可以随时处理触摸事件。
工作原理…
为了处理队列中的触摸事件,我们扩展了DrawFrame()
JNI 回调的实现:
Java_com_packtpub_ndkcookbook_game1_Game1Activity_DrawFrame(
JNIEnv* env, jobject obj )
{
注意在额外的{}
内的Lock
变量的作用域。我们需要它,因为必须在继续游戏逻辑之前解锁互斥变量,以防止死锁:
{
LMutex Lock(&g_MotionEventsQueueMutex );
for( auto m : g_MotionEventsQueue )
{
GestureHandler_SendMotion( m.ContactID, m.Flag,
m.Pos, m.Pressed );
}
g_MotionEventsQueue.clear();
}
GenerateTicks();
}
注意
请查看示例1_Game
中的jni/Wrappers.cpp
文件,以获取完整的实现,可以从www.packtpub.com/support获取。
还有更多…
我们的新方法更加健壮。然而,在GestureHandler_SendMotion()
内部生成的触摸事件时间戳稍微有些健壮,不再对应于触摸的实际时间。这引入了一个大约等于单帧渲染时间的延迟,在多人游戏中可能成为一个问题。我们将添加真实时间戳的练习留给读者。这可以通过扩展sSendMotionData
结构体,添加一个时间戳字段来完成,该字段在 JNI 回调SendMotion()
内部赋值。
另请参阅
- 第七章,跨平台 UI 和输入系统中的在 Android 上处理多触摸事件配方
改进音频播放机制
在前面的章节中,我们学习了如何在 Android 上使用 OpenAL 播放音频。我们在第五章,跨平台音频流中实现的基本音频子系统缺乏对音频源的自动管理;我们不得不在单独的线程上手动控制它们。现在,我们将把所有这些代码放入一个新的音频子系统中,以便在实际游戏中使用。
准备就绪
此配方的完整源代码已集成到示例1_Game
中,可以在文件sound/Audio.h
和sound/Audio.cpp
中找到。sound
文件夹中的其他文件提供了对不同音频格式的解码能力——可以查看它们。
如何操作…
-
我们需要我们的
clAudioThread
类来处理活动音频源。让我们通过负责注册的方法来扩展它:class clAudioThread: public iThread { public: … void RegisterSource( clAudioSource* Src ); void UnRegisterSource( clAudioSource* Src );
-
我们还需要一个用于活动源的容器以及控制对其访问的互斥锁:
private: … std::vector< clAudioSource* > FActiveSources; Mutex FMutex; };
-
clAudioThread::Run()
方法变得更加复杂。除了初始化 OpenAL 之外,它还必须更新活动音频源,以便它们可以从提供者那里获取音频数据:void clAudioThread::Run() { if ( !LoadAL() ) { return; } FDevice = alcOpenDevice( NULL ); FContext = alcCreateContext( FDevice, NULL ); alcMakeContextCurrent( FContext ); FInitialized = true; FPendingExit = false; double Seconds = GetSeconds();
-
内部循环根据经过的时间更新活动音频源:
while ( !IsPendingExit() ) { float DeltaSeconds = static_cast<float>( GetSeconds() - Seconds );
-
注意以下互斥锁的作用域:
{ LMutex Lock(&FMutex ); for( auto i = FActiveSources.begin(); i != FActiveSources.end(); i++ ) { ( *i )->Update( DeltaSeconds ); } } Seconds = GetSeconds();
-
音频源每 100 毫秒更新一次。这个值纯粹是经验性的,适用于非实时音频播放,作为音频子系统滞后与 Android 设备功耗之间的折中:
Env_Sleep( 100 ); } alcDestroyContext( FContext ); alcCloseDevice( FDevice ); UnloadAL(); }
-
需要注册方法来维护
FActiveSources
容器。它们的实现可以在以下代码中找到:void clAudioThread::RegisterSource( clAudioSource* Src ) { LMutex Lock(&FMutex );
-
不要多次添加同一个音频源:
auto i = std::find( FActiveSources.begin(), FActiveSources.end(), Src ); if ( i != FActiveSources.end() ) return; FActiveSources.push_back( Src ); } void clAudioThread::UnRegisterSource( clAudioSource* Src ) { LMutex Lock(&FMutex );
-
只需找到源并删除它:
auto i = std::find( FActiveSources.begin(), FActiveSources.end(), Src ); if ( i != FActiveSources.end() ) FActiveSources.erase( i ); }
这个新的clAudioThread
类的完整实现在示例1_Game
中的sound/Audio.cpp
和sound/Audio.h
文件中可以找到。
工作原理…
为了利用新的AudioThread
类,音频源必须注册自己。我们扩展了clAudioSource
类的构造函数和析构函数,以执行 RAII 注册(en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization
):
clAudioSource::clAudioSource()
{
…
g_Audio.RegisterSource( this );
}
clAudioSource::~clAudioSource()
{
…
g_Audio.UnRegisterSource( this );
}
现在音频播放非常简单。声明一个全局音频线程:
clAudioThread g_Audio;
从主线程开始,等待初始化完成:
g_Audio.Start( iThread::Priority_Normal );
g_Audio.Wait();
注意
我们可以在g_Audio.Start()
和g_Audio.Wait()
调用之间调用其他有用的初始化例程,以利用异步初始化。
创建并配置一个新的音频源并播放它:
Music = new clAudioSource();
Music->BindWaveform(new
clModPlugProvider( LoadFileAsBlob("test.xm")) );
Music->LoopSound( true );
Music->Play();
所有的音频管理现在都在另一个线程上完成。
还有更多…
我们的音频线程能够播放不同类型的音频文件,如.ogg
,.xm
,.it
和.s3m
文件。你可以通过向AudioSource
添加另一个方法来隐藏适当 wavedata 提供者的创建。只需根据文件扩展名切换选择以创建ModPlugProvider
或OggProvider
实例。我们把这个作为一个练习留给你。
另请参阅
- 在第五章,跨平台音频流中的初始化 OpenAL 和播放.wav 文件,解码 Ogg Vorbis 文件,使用 ModPlug 解码跟踪器音乐,以及流式声音食谱
关闭应用程序
智能手机的电池非常有限,这使得移动设备对任何后台活动都非常敏感。我们之前的应用示例在用户切换到另一个活动后仍然保持运行。这意味着我们没有尊重 Android 活动生命周期(developer.android.com/training/basics/activity-lifecycle
),在后台继续浪费宝贵的系统资源,而是应该在onPause()
回调中暂停我们的应用程序。
准备就绪
如果你不太熟悉 Android 活动生命周期,请参考开发者手册:developer.android.com/training/basics/activity-lifecycle/index.html
。
如何实现…
-
一个 Android 应用程序不必实现所有的生命周期方法。我们的生命周期管理策略将非常简单;一旦调用
onPause()
方法,保存游戏状态并终止应用程序。我们需要编写一些 Java 代码来实现这个功能。将这段代码添加到你的Activity
类中,在我们的例子中是Game1Activity.java
文件中的Game1Activity
类:@Override protected void onPause() { super.onPause(); ExitNative(); } public static native void ExitNative();
-
按照以下方式实现
ExitNative()
JNI 方法:JNIEXPORT void JNICALL Java_com_packtpub_ndkcookbook_game1_Game1Activity_ExitNative( JNIEnv* env, jobject obj ) { OnStop(); exit( 0 ); }
-
现在我们可以在我们的游戏中实现本地
OnStop()
回调。
它是如何工作的…
OnStop()
回调的典型实现将保存游戏状态,以便稍后游戏恢复时可以恢复状态。由于我们的第一个游戏不需要任何保存,我们只提供一个空的实现:
void OnStop()
{
}
你可能想要稍后作为一个练习来实现游戏保存。
还有更多…
要使OnStop()
方法在 Windows 上工作,只需在Wrapper_Windows.cpp
中的主循环退出后调用它:
while ( !PendingExit )
{
…
}
OnStop();
现在这个解决方案是可移植的,所有的逻辑都可以在 Windows 上进行调试。
另请参阅
- 实现主循环
实现主循环
在前面的章节中,我们的代码示例使用了带有粗略固定时间步长的OnTimer()
回调来更新状态,以及OnDrawFrame()
回调来渲染图形。这对于需要根据自上一帧以来经过的真实时间来更新状态的真实游戏来说是不合适的。然而,我们仍然希望使用较小的固定时间步长在OnTimer()
的调用中。我们可以通过巧妙地交错调用OnTimer()
和OnDrawFrame()
,并将此逻辑放入游戏主循环中,来解决此问题。
准备就绪
在gafferongames.com/game-physics/fix-your-timestep
有一篇非常有趣的文章,名为修复你的时间步长!,它详细解释了实现游戏主循环的不同方法以及固定时间步长的重要性。
如何操作…
-
游戏主循环的逻辑与平台无关,可以放入一个方法中:
void GenerateTicks() {
-
GetSeconds()
返回自系统启动以来的单调时间(秒)。然而,只有帧差是重要的:NewTime = GetSeconds(); float DeltaSeconds = static_cast<float>( NewTime - OldTime ); OldTime = NewTime;
-
我们将使用与每秒 60 帧运行的游戏相对应的固定时间步长来更新游戏逻辑:
const float TIME_QUANTUM = 1.0f / 60.0f;
-
同时,我们还需要一个故障安全机制,以防止由于渲染速度慢而导致的游戏过度减慢。
const float MAX_EXECUTION_TIME = 10.0f * TIME_QUANTUM;
-
现在,我们累积经过的时间:
ExecutionTime += DeltaSeconds; if ( ExecutionTime > MAX_EXECUTION_TIME ) { ExecutionTime = MAX_EXECUTION_TIME; }
-
并相应地调用一系列
OnTimer()
回调函数。所有的OnTimer()
回调都接收相同的固定时间步长值:while ( ExecutionTime > TIME_QUANTUM ) { ExecutionTime -= TIME_QUANTUM; OnTimer( TIME_QUANTUM ); }
-
更新游戏后,渲染下一帧:
OnDrawFrame(); }
工作原理…
OnDrawFrame()
回调应该在更新后调用。如果设备足够快,每次OnTimer()
调用后都会调用OnDrawFrame()
。否则,为了保持游戏逻辑的实时速度,将跳过一些帧。如果设备太慢以至于无法运行游戏逻辑,我们的保护代码将启动:
if ( ExecutionTime > MAX_EXECUTION_TIME )
{ ExecutionTime = MAX_EXECUTION_TIME; }
整个过程将以慢动作进行,但游戏仍然可以玩。
注意
你可以尝试调整传递给OnTimer()
的值,例如OnTimer( k * TIME_QUANTUM )
。如果k
小于1.0
,游戏逻辑将变为慢动作。它可以用来制作类似于子弹时间(en.wikipedia.org/wiki/Bullet_time
)的效果。
还有更多…
如果应用程序被挂起,但你想让它继续在后台运行,最好完全省略渲染阶段或更改更新量子的持续时间。你可以通过为你的游戏添加Paused
状态并在主循环中检查它,例如:
if ( !IsPaused() ) OnDrawFrame();
这将有助于在后台运行游戏逻辑模拟的同时节省宝贵的 CPU 周期。
另请参阅
- 第二章中实现物理中的定时的食谱,移植通用库
创建一个多平台游戏引擎
在前面的章节和食谱中,我们手工制作了许多针对多平台游戏开发任务的临时解决方案。现在,我们将所有相关的代码整合到一个初生的便携式游戏引擎中,并学习如何为 Windows 和 Android 准备 makefile 以构建它。
准备就绪。
要了解这个食谱中发生的情况,建议你从本书开始阅读第一章到第七章。
如何操作...
-
我们将所有代码分成几个逻辑子系统,并将它们放入以下文件夹中:
-
core
:这包含低级别的设施,例如侵入式智能指针和数学库。 -
fs
:这包含与文件系统相关的类。 -
GL
:这包含官方的 OpenGL 头文件。 -
include
:这包含一些第三方库的头文件。 -
graphics
:这包含高级图形相关代码,如字体、画布和图像。 -
LGL
:这包含我们在 第七章 中实现的 OpenGL 包装器和函数加载代码以及抽象层,跨平台 UI 和输入系统。 -
Sound
:这包含音频相关类和解码库。 -
threading
:这包含与多线程相关的类,包括互斥量、事件、队列和我们的多平台线程包装器。
-
它是如何工作的...
每个文件夹中的大部分代码都被分成了类。在我们的简约游戏引擎中,我们尽量保持类的数量在一个合理的最低限度。
graphics
文件夹包含了以下结构和类的实现:
-
结构体
sBitmapParams
保存位图的参数,如宽度、高度和像素格式。 -
类
clBitmap
是一个与 API 独立的位图表示,保存实际的像素数据以及sBitmapParams
。它可以加载到 clGLTexture 中。 -
类
clCanvas
提供了一种立即渲染的机制。 -
类
clVertexAttribs
是一个与 API 独立的 3D 几何表示。它可以加载到clGLVertexArray
中。 -
类
clGeomServ
提供了创建 3D 几何的方法,返回clVertexAttribs
。 -
类
iGestureResponder
是一个接口,如果你想要响应触摸或手势,就需要实现这个接口。 -
结构体
sMotionData
保存当前激活的触摸点集合。 -
类
clTextRenderer
提供基于 FreeType 的文本渲染设施。它可以指定字体将文本字符串渲染到clBitmap
中。 -
结构体
sTouchPoint
表示一个带有标识符、2D 归一化浮点坐标、标志和时间戳的单个触摸点。
LGL
文件夹保存了特定于 OpenGL 的类:
-
结构体
sUniform
表示着色器程序中的一个统一变量。它只是一个名称和位置索引。 -
类
clGLSLShaderProgram
表示一个用 GLSL 编写的着色器程序,并提供桌面 GLSL 与移动 GLSL ES 之间的自动转换功能。 -
类
clGLTexture
提供对 OpenGL 纹理的访问,并可以读取clBitmap
的像素数据。 -
类
clGLVertexArray
提供了对 OpenGL 顶点数组对象和顶点缓冲对象的抽象。它使用来自clVertexAttribs
的数据。
低级类,如智能指针、侵入式计数器和数学相关代码被放入 core
文件夹:
-
类
clPtr
是一个引用计数式侵入式智能指针的实现。 -
类
iObject
持有一个侵入式引用计数器。 -
类
LRingBuffer
是一个环绕式环形缓冲区的实现。 -
基本数学库包括向量类,如
LVector2
、LVector3
、LVector4
、LVector2i
和矩阵类,如LMatrix3
和LMatrix4
。数学库还包含设置投影的最小代码。
文件系统相关的代码位于 fs
文件夹中:
-
类
clArchiveReader
使用 libcompress 库实现.zip
归档解压算法。它用于访问 Android.apk
文件中的资源。 -
类
clBlob
表示内存中的字节数组,可以从中读取或写入文件。 -
类
iRawFile
是所有表示文件的类的基类。 -
类
clRawFile
表示物理文件系统上的文件。 -
类
clMemRawFile
将内存块表示为文件,适用于访问下载的数据(例如图像)。 -
类
clManagedMemRawFile
与MemRawFile
类似,但内存由内部的Blob
对象管理。 -
类
clFileMapper
是只读内存映射文件的抽象。 -
类
clFileWriter
是写入文件的抽象。 -
类
clFileSystem
是流和块(blobs)的工厂。它提供了管理我们应用程序中虚拟路径的功能。 -
类
iMountPoint
、clPhysicalMountPoint
、clAliasMountPoint
和clArchiveMountPoint
用于以可移植的多平台方式路由到操作系统本地文件系统和 Android.apk
归档的访问。
sound
文件夹包含我们音频子系统的抽象:
-
类
clAudioSource
表示虚拟环境中的音频源。它可以播放、暂停或停止。 -
类
clAudioThread
更新活动源并将数据提交到底层的 OpenAL API。 -
类
iWaveDataProvider
抽象了音频文件的解码。 -
类
clStreamingWaveDataProvider
从太大而不能一次性解码到内存中的音频文件流式传输数据。 -
类
clDecodingProvider
为流式音频提供者提供公共倒带逻辑。它是实际解码器的基类。 -
类
clOggProvider
和clModPlugProvider
使用 libogg/libvorbis 处理.ogg
文件的解码和 libmodplug 处理跟踪音乐。
threading
文件夹包含不同多线程原语的可移植实现:
-
类
clMutex
、LMutex
和iThread
以可移植的方式实现了基本的低级多线程原语。 -
类
clWorkerThread
和iTask
是基于iThread
的高级抽象。 -
类
iAsyncQueue
和iAsyncCapsule
用于实现异步回调。注意
我们小型引擎的源代码位于上一章示例中的 Engine 文件夹内。
另请参阅
-
编写匹配-3 游戏
-
第九章, 编写图片拼图游戏
编写匹配-3 游戏
现在是开始开发一个完整的匹配-3游戏的时候了。匹配-3 是一种拼图类型,玩家需要排列瓷砖以使相邻的瓷砖消失。这里,3
表示当相同颜色的瓷砖相邻放置时将消失的数量。以下截图是游戏的最终版本:
在我们的游戏中使用了 22 种单块、双块、三块、四块和五块形状。
由于大部分印象来自于屏幕上可视化的结果,让我们继续了解游戏屏幕渲染的基本要点。
准备就绪
完整的、可直接构建的源代码位于补充材料中的1_Game
文件夹。
这款游戏于 2011 年由本书作者在 Google Play 以某种扩展形式发布。如果你想立即在 Android 设备上尝试这款游戏,可以在以下网站找到:play.google.com/store/apps/details?id=com.linderdaum.engine.multibricks
和 play.google.com/store/apps/details?id=com.linderdaum.engine.multibricks_free
。
如果你在自己的项目中使用这款游戏的图形作品,作者并不介意。这是一个学习工具,而不是商品。
对通用匹配-3 游戏机制感兴趣的人可以参考以下维基百科文章:en.wikipedia.org/wiki/Match_3
。
如何操作…
每帧都在OnDrawFrame()
回调中通过几个步骤重新渲染整个游戏屏幕。让我们通过源代码看看如何操作:
-
全屏背景图像在清除前一个帧的图形后渲染。图像存储为 512 x 512 的方形
.png
文件,并按比例缩放到全屏,如下截图所示:注意
为了使游戏兼容旧的 Android 硬件,使用了 2 的幂次图像。如果你的最低要求是 OpenGL ES 3,可以使用任意大小的纹理。
-
以下是渲染背景的 C++代码:
LGL3->glDisable( GL_DEPTH_TEST );
-
首先,绑定 3 个纹理和着色器:
BackTexture_Bottom->Bind(2); BackTexture_Top->Bind(1); BackTexture->Bind(0); BackShader->Bind();
-
更新控制按钮的按下标志:
BackShader->SetUniformNameFloatArray( "b_MoveLeft", 1, b_Flags[b_MoveLeft] ); BackShader->SetUniformNameFloatArray( "b_Down", 1, b_Flags[b_Down] ); BackShader->SetUniformNameFloatArray( "b_MoveRight", 1, b_Flags[b_MoveRight] ); BackShader->SetUniformNameFloatArray( "b_TurnLeft", 1, b_Flags[b_TurnLeft] ); BackShader->SetUniformNameFloatArray( "b_TurnRight", 1, b_Flags[b_TurnRight] ); BackShader->SetUniformNameFloatArray( "b_Reset", 1, b_Flags[b_Reset] ); BackShader->SetUniformNameFloatArray( "b_Paused", 1, b_Flags[b_Paused] );
-
最后,渲染一个全屏矩形:
Canvas->GetFullscreenRect()->Draw(false);
-
float b_Flags[]
数组对应于控制按钮的状态;1.0f
的值表示按钮被按下,0.0f
表示按钮被释放。这些值被传递给着色器,相应地突出显示按钮。 -
游戏场地的单元格在背景之上渲染,然后是当前形状:
for ( int i = 0; i < g_Field.FWidth; i++ ) { for ( int j = FIELD_INVISIBLE_RAWS;j < g_Field.FHeight; j++ ) { int c = g_Field.FField[i][j]; if ( c >= 0 && c < NUM_COLORS ) { int Img = c % NUM_BRICK_IMAGES; int P = ( j - FIELD_INVISIBLE_RAWS );
-
场的每个单元格只是一个带有纹理的小矩形:
DrawTexQuad( i * 20.0f + 2.0f, P * 20.0f + 2.0f,16.0f, 16.0f, Field_X1, Field_Y1, g_Colors[c], Img ); } } }
-
当前行形状在一行中渲染:
DrawFigure(&g_CurrentFigure, g_GS.FCurX, g_GS.FCurY - FIELD_INVISIBLE_RAWS,Field_X1, Field_Y1, BLOCK_SIZE );
-
下一个图形在控制按钮附近渲染,如下面的截图所示:
-
代码更为复杂,因为我们需要计算形状的边界框以正确渲染它:
int Cx1, Cy1, Cx2, Cy2; g_NextFigure.GetTopLeftCorner(&Cx1, &Cy1 ); g_NextFigure.GetBottomRightCorner(&Cx2, &Cy2 ); LRect FigureSize = g_NextFigure.GetSize(); float dX = ( float )Cx1 * BLOCK_SIZE_SMALL / 800.0f; float dY = ( float )Cy1 * BLOCK_SIZE_SMALL / 600.0f; float dX2 = 0.5f * (float)Cx2 * BLOCK_SIZE_SMALL/800.0f; float dY2 = 0.5f * (float)Cy2 * BLOCK_SIZE_SMALL/600.0f; DrawFigure( &g_NextFigure, 0, 0, 0.415f - dX - dX2, 0.77f - dY - dY2, BLOCK_SIZE_SMALL );
-
渲染当前分数文本,如下面的截图所示:
-
文本一旦更改,就会被渲染成位图,并更新纹理:
std::string ScoreString( Str_GetFormatted( "%02i:%06i", g_GS.FLevel, g_GS.FScore ) ); if ( g_ScoreText != ScoreString ) { g_ScoreText = ScoreString; g_ScoreBitmap = g_TextRenderer->RenderTextWithFont( ScoreString.c_str(), g_Font,32, 0xFFFFFFFF, true ); g_ScoreTexture->LoadFromBitmap( g_ScoreBitmap ); }
-
我们只需在每一帧中渲染一个带有纹理的矩形:
LVector4 Color( 0.741f, 0.616f, 0.384f, 1.0f ); Canvas->TexturedRect2D( 0.19f, 0.012f, 0.82f, 0.07f,Color, g_ScoreTexture );
-
如果需要,渲染游戏结束信息,如下面的截图所示:
-
这与文本渲染类似,然而,由于这个消息框显示得不频繁,我们可以避免缓存:
if ( g_GS.FGameOver ) { DrawBorder( 0.05f, 0.25f, 0.95f, 0.51f, 0.19f ); std::string ScoreStr = Str_GetPadLeft( Str_ToStr( g_GS.FScore ), 6, '0' ); Canvas->TextStr( 0.20f, 0.33f, 0.84f, 0.37f, LocalizeString("Your score:"), 32, LVector4( 0.796f, 0.086f,0.086f, 1.0f ), g_TextRenderer, g_Font ); Canvas->TextStr( 0.20f, 0.38f, 0.84f, 0.44f,ScoreStr, 32, LVector4( 0.8f, 0.0f, 0.0f,1.0f ), g_TextRenderer, g_Font ); }
-
Canvas 完成了渲染文本和更新纹理所需的所有工作。然而,对于更频繁的操作来说,它有点慢。查看
graphics/Canvas.cpp
文件中的完整实现。
工作原理…
在前面的代码中,我们使用了一些辅助函数,可能需要一些解释。DrawQuad()
和DrawTexQuad()
函数绘制游戏场的一个单元格。它们包含一些硬编码的值,用于将单元格相对于背景图像定位。以下是其中一个函数的源代码:
void DrawTexQuad( float x, float y, float w, float h,
float OfsX, float OfsY,
const LVector4& Color, int ImageID )
{
800.0f
和600.0f
的魔法常数在这里出现,用于将 UI 坐标系统(为600×800
纵向屏幕设计)转换为浮点标准化坐标:
float X1 = x / 800.0f;
float Y1 = y / 600.0f;
float X2 = ( x + w ) / 800.0f;
float Y2 = ( y + h ) / 600.0f;
其他魔法常数也是设计的一部分,是通过经验选择的。尝试调整它们:
X1 *= Field_Width / 0.35f;
X2 *= Field_Width / 0.35f;
Y1 *= Field_Height / 0.75f;
Y2 *= Field_Height / 0.75f;
Canvas->TexturedRect2D( X1 + OfsX, Y1 + OfsY,
X2 + OfsX, Y2 + OfsY,
Color, BricksImage[ImageID] );
}
DrawFigure()
方法用于在游戏场地的任何位置绘制单个形状:
void DrawFigure( clBricksShape* Figure, int X, int Y,
float OfsX, float OfsY, float BlockSize )
{
for ( int i = 0 ; i < Figure->FWidth ; i++ )
{
for ( int j = 0 ; j < Figure->FHeight ; j++ )
{
跳过游戏场顶部不可见的行:
if ( Y + j < 0 ) { continue; }
intc = Figure->GetMask( i, j );
if ( c >= 0 && c < NUM_COLORS )
{
DrawTexQuad(
(X + i) *(BlockSize + 4.0f) + 2.0f,
(Y + j) * (BlockSize + 4.0f) + 2.0f,
BlockSize, BlockSize, OfsX, OfsY,
g_Colors[c], c % NUM_BRICK_IMAGES );
}
}
}
}
DrawBorder()
函数只是Canvas
的一个快捷方式:
void DrawBorder( float X1, float Y1, float X2, float Y2,
float Border )
{
Canvas->TexturedRect2D( X1, Y1, X1+Border, Y2,
LVector4( 1.0f ), MsgFrameLeft );
Canvas->TexturedRect2D( X2-Border, Y1, X2, Y2,
LVector4( 1.0f ), MsgFrameRight );
Canvas->TexturedRect2DTiled( X1+Border, Y1, X2-Border, Y2,
3, 1, LVector4( 1.0f ), MsgFrameCenter );
}
还有更多…
我们提到过,控制按钮在片段着色器中会被突出显示。以下是实现方法。
将按钮的状态作为统一变量传递:
uniform float b_MoveLeft;
uniform float b_Down;
uniform float b_MoveRight;
uniform float b_TurnLeft;
uniform float b_TurnRight;
uniform float b_Reset;
uniform float b_Paused;
检查矩形是否包含指定点的函数如下:
bool ContainsPoint( vec2 Point, vec4 Rect )
{
return Point.x >= Rect.x && Point.y >= Rect.y &&
Point.x <= Rect.z && Point.y <= Rect.w;
}
存储一些硬编码的值,对应于我们的控制按钮所在的位置:
void main()
{
const vec4 MoveLeft = vec4( 0.0, 0.863, 0.32, 1.0 );
const vec4 Down = vec4( 0.32, 0.863, 0.67, 1.0 );
const vec4 MoveRight = vec4( 0.67, 0.863, 1.0, 1.0 );
const vec4 TurnLeft = vec4( 0.0, 0.7, 0.4, 0.863);
const vec4 TurnRight = vec4( 0.6, 0.7, 1.0, 0.863);
const vec4 Reset = vec4( 0.0, 0.0, 0.2, 0.1 );
const vec4 Paused = vec4( 0.8, 0.0, 1.0, 0.1 );
阅读背景纹理和突出部分。查看随附项目中的back.png
、back_high_bottom.png
和back_high_top.png
文件:
vec4 Color = texture( Texture0,TexCoord );
vec4 ColorHighT = texture( Texture1,TexCoord*vec2(4.0,8.0) );
vec4 ColorHighB = texture( Texture2,TexCoord*vec2(1.0,2.0) );
检查按钮是否被按下,并相应地选择正确的纹理:
if ( b_MoveLeft>0.5 &&ContainsPoint(TexCoord.xy, MoveLeft))
Color = ColorHighB;
if ( b_Down> 0.5 && ContainsPoint( TexCoord.xy, Down ) )
Color = ColorHighB;
if ( b_MoveRight>0.5 && ContainsPoint(TexCoord.xy,MoveRight) )
Color = ColorHighB;
if ( b_TurnLeft>0.5 && ContainsPoint(TexCoord.xy, TurnLeft) )
Color = ColorHighB;
if ( b_TurnRight>0.5 && ContainsPoint(TexCoord.xy,TurnRight) )
Color = ColorHighB;
if ( b_Reset> 0.5 && ContainsPoint( TexCoord.xy, Reset) )
Color = ColorHighT;
if ( b_Paused> 0.5 && ContainsPoint( TexCoord.xy, Paused ) )
Color = ColorHighT;
哇!我们只用一次传递就为所有按钮纹理化了背景:
out_FragColor = Color;
}
另请参阅
- 创建一个多平台游戏引擎
管理形状
在上一个食谱中,我们学习了如何渲染游戏屏幕。有些类尚未实现。在本食谱中,我们将实现clBricksShape
类,负责存储和操作游戏中出现的每个形状。
准备就绪
看看可以存在多少不同的五格拼板形状。维基百科提供了一个全面的概述:en.wikipedia.org/wiki/Pentomino
。
如何操作…
-
我们的
clBricksShape
类的接口如下所示:class clBricksShape { public:
-
我们游戏中使用的形状大小。我们使用
5x5
的形状。static const int FWidth = SHAPES_X; static const int FHeight = SHAPES_Y;
-
存储构成这个形状的单元格的颜色。颜色作为索引存储:
private: int FColor[NUM_COLORS];
-
图形索引定义了形状类型:
int FFigureIndex;
-
旋转索引对应于图形的旋转角度:
0
、1
、2
和3
分别代表0
、90
、180
和270
度:int FRotationIndex;
-
这些方法非常简短直接,如下所示:
public: int GetMask( int i, int j ) const { if ( i < 0 || j < 0 ) return -1; if ( i >= FWidth || j >= FHeight ) return -1; int ColorIdx = Shapes[FFigureIndex][FRotationIndex][i][j]; return ColorIdx ? FColor[ColorIdx] : -1; }
-
Rotate()
方法并不旋转单个单元格。它什么也不做,只是调整旋转角度:void Rotate( bool CW ) { FRotationIndex = CW ? ( FRotationIndex ? FRotationIndex - 1 : ROTATIONS - 1 ) : ( FRotationIndex + 1 ) % ROTATIONS; }
-
图形生成也非常简单。它只是从预定义图形的表格中选择:
void GenFigure( int FigIdx, int Col ) { for ( int i = 0; i != NUM_COLORS; i++ ) FColor[i] = Random( NUM_COLORS ); FFigureIndex = FigIdx; FRotationIndex = 0; }
-
这些方法用于计算形状的边界框。参考《game/Shape.h》文件以获取它们的源代码:
void GetTopLeftCorner( int* x, int* y ) const; void GetBottomRightCorner( int* x, int* y ) const; LRect GetSize() const; };
工作原理…
前一节代码的主要技巧在于预定义形状的表格。其声明位于《Pentomino.h》文件中:
static const int NUM_SHAPES = 22;
static const int SHAPES_X = 5;
static const int SHAPES_Y = 5;
static const int ROTATIONS = 4;
extern char
Shapes[ NUM_SHAPES ][ ROTATIONS ][ SHAPES_X ][ SHAPES_Y ];
就是这样。我们将每一个形状存储在这个 4D 数组中。《Pentomino.cpp》文件定义了数组的内容。以下代码是定义单个形状所有 4 种旋转的摘录:
char Shapes [ NUM_SHAPES ][ ROTATIONS ][ SHAPES_X ][ SHAPES_Y ] =
{
{
{
{0, 0, 0, 0, 0},
{0, 0, 0, 1, 0},
{0, 0, 3, 2, 0},
{0, 5, 4, 0, 0},
{0, 0, 0, 0, 0}
},
{
{0, 0, 0, 0, 0},
{0, 5, 0, 0, 0},
{0, 4, 3, 0, 0},
{0, 0, 2, 1, 0},
{0, 0, 0, 0, 0}
},
{
{0, 0, 0, 0, 0},
{0, 0, 4, 5, 0},
{0, 2, 3, 0, 0},
{0, 1, 0, 0, 0},
{0, 0, 0, 0, 0}
},
{
{0, 0, 0, 0, 0},
{0, 1, 2, 0, 0},
{0, 0, 3, 4, 0},
{0, 0, 0, 5, 0},
{0, 0, 0, 0, 0}
}
},
数组中的非零值定义了哪些单元格属于形状。值的绝对定义了单元格的颜色。
另请参阅
- 编写匹配-3 游戏
管理游戏场逻辑
现在我们知道如何存储不同的形状并渲染它们。让我们实现一些游戏逻辑,让这些形状在游戏场中相互交互。
准备就绪
参阅《编写匹配-3 游戏》的菜谱,了解如何渲染游戏场。
如何操作…
-
clBricksField
的接口如下所示:class clBricksField { public:
-
我们的游戏场大小为
11×22
:static const int FWidth = 11; static const int FHeight = 22; public: void clearField()
-
检查图形是否可以自由地放入某个位置的方法如下:
bool figureFits( int x, int y, const clBricksShape& fig ) bool figureWillHitNextTurn( int x, int y, const clBricksShape& fig )
-
这个方法将形状印在游戏场的指定位置:
void addFigure( int x, int y, const clBricksShape& fig )
-
以下代码是主要的游戏逻辑。计算并删除同色单元格区域的方法:
int deleteLines(); int CalcNeighbours( int i, int j, int Col ); void FillNeighbours( int i, int j, int Col );
-
由于我们正在制作一个匹配-3 游戏,因此我们给这个方法传递了
3
的值。然而,逻辑是通用的;你可以使用自己的值调整游戏玩法:int deleteRegions( int NumRegionsToDelete ); void collapseField();
-
游戏场的单元格存储在这里。值对应于单元格的颜色:
public: int FField[ FWidth ][ FHeight ]; };
工作原理…
形状拟合使用简单的遮罩检查,非常简单。我们将更多关注邻近单元格的计算。它基于递归的洪水填充算法(en.wikipedia.org/wiki/Flood_fill
):
int clBricksField::deleteRegions( int NumRegionsToDelete )
{
int NumRegions = 0;
for ( int j = 0; j != FHeight; j++ )
{
for ( int i = 0 ; i != FWidth ; i++ )
{
if ( FField[i][j] != -1 )
{
递归地计算每个单元格的邻居数量:
int Neighbors = CalcNeighbours( i, j,
FField[i][j] );
如果邻居数量足够多,则标记单元格:
if ( Neighbors >= NumRegionsToDelete )
{
FillNeighbours( i, j, FField[i][j] );
NumRegions += Neighbours;
}
}
}
}
从游戏场中移除标记的单元格:
CollapseField();
返回删除区域的数量。这用于评估当前分数:
return NumRegions;
}
递归的洪水填充是直接的。以下代码计算相邻单元格的数量:
intclBricksField::CalcNeighbours( int i, int j, int Col )
{
if ( i < 0 || j < 0 || i >= FWidth ||
j >= FHeight || FField[i][j] != Col ) return 0;
FField[i][j] = -1;
int Result = 1 + CalcNeighbours( i + 1, j + 0, Col ) +
CalcNeighbours( i - 1, j + 0, Col ) +
CalcNeighbours( i + 0, j + 1, Col ) +
CalcNeighbours( i + 0, j - 1, Col );
FField[i][j] = Col;
return Result;
}
以下代码标记相邻的单元格:
void clBricksField::FillNeighbours( int i, int j, int Col )
{
if ( i < 0 || j < 0 || i >= FWidth ||
j >= FHeight || FField[i][j] != Col ) { return; }
FField[i][j] = -1;
FillNeighbours( i + 1, j + 0, Col );
FillNeighbours( i - 1, j + 0, Col );
FillNeighbours( i + 0, j + 1, Col );
FillNeighbours( i + 0, j - 1, Col );
}
还有更多…
这个项目中还实现了另一种游戏逻辑变体。查看文件 game/Field.h
中的 deleteLines()
方法以了解如何实现它。
在游戏循环中实现用户交互
在之前的食谱中,我们学习了如何渲染游戏环境并实现游戏逻辑。开发中还有一个重要的方面需要我们关注:用户交互。
准备就绪
查看项目 1_Game
中的 main.cpp
文件以获取完整实现。
如何操作…
我们需要实现一些函数来移动当前下落的形状:
-
在移动图形左右时强制执行游戏场地限制:
bool MoveFigureLeft() { if ( g_Field.FigureFits( g_GS.FCurX - 1, g_GS.FCurY, g_CurrentFigure ) ) { g_GS.FCurX--; return true; } return false; }
-
MoveFigureRight()
的源代码与MoveFigureLeft()
类似。MoveFigureDown()
的代码需要在形状触地后更新得分:bool MoveFigureDown() { if ( g_Field.FigureFits( g_GS.FCurX, g_GS.FCurY + 1, g_CurrentFigure ) ) { g_GS.FScore += 1 + g_GS.FLevel / 2; g_GS.FCurY++; return true; } return false; }
-
旋转代码需要检查旋转是否实际可行:
bool RotateFigure( bool CW ) { clBricksShape TempFigure( g_CurrentFigure ); TempFigure.Rotate( CW ); if ( g_Field.FigureFits(g_GS.FCurX, g_GS.FCurY, TempFigure)) { g_CurrentFigure = TempFigure; return false; } return true; }
-
我们需要响应按键或触摸来调用这些方法。
工作原理…
ProcessClick()
函数处理单个点击。为了简化代码,我们将点击位置存储在全局变量 g_Pos
中:
void ProcessClick( bool Pressed )
{
重置按钮的状态:
b_Flags[b_MoveLeft] = 0.0f;
b_Flags[b_MoveRight] = 0.0f;
b_Flags[b_Down] = 0.0f;
b_Flags[b_TurnLeft] = 0.0f;
b_Flags[b_TurnRight] = 0.0f;
b_Flags[b_Paused] = 0.0f;
b_Flags[b_Reset] = 0.0f;
bool MousePressed = Pressed;
if ( Reset.ContainsPoint( g_Pos ) )
{
if ( MousePressed ) { ResetGame(); }
b_Flags[b_Reset] = MousePressed ? 1.0f : 0.0f;
}
一旦游戏结束,不允许按下任何按钮:
if ( g_GS.FGameOver ) { if ( !Pressed ) ResetGame(); return; }
运行操作并更新按钮的高亮状态:
if ( Pressed )
{
if ( MoveLeft.ContainsPoint( g_Pos ) )
{ MoveFigureLeft(); b_Flags[b_MoveLeft] = 1.0f; }
if ( MoveRight.ContainsPoint( g_Pos ) )
{ MoveFigureRight(); b_Flags[b_MoveRight] = 1.0f; }
if ( Down.ContainsPoint( g_Pos ) )
{
if ( !MoveFigureDown() ) { NextFigure(); } b_Flags[b_Down] = 1.0f;
}
if ( TurnLeft.ContainsPoint( g_Pos ) )
{ rotateFigure( false ); b_Flags[b_TurnLeft] = 1.0f; }
if ( TurnRight.ContainsPoint( g_Pos ) )
{ rotateFigure( true ); b_Flags[b_TurnRight] = 1.0f; }
if ( Paused.ContainsPoint( g_Pos ) )
{
b_Flags[b_Paused] = 1.0f;
这被用于在触摸屏上实现自动重复:
g_KeyPressTime = 0.0f;
}
}
}
还有更多…
我们游戏的主循环是在 OnTimer()
回调中实现的:
void OnTimer( float DeltaTime )
{
if ( g_GS.FGameOver ) { return; }
g_GS.FGameTimeCount += DeltaTime;
g_GS.FGameTime += DeltaTime;
g_KeyPressTime += DeltaTime;
在这里,我们检查标志位的值以在触摸屏上实现方便的自动重复:
if ( (b_Flags[b_MoveLeft] > 0 ||
b_Flags[b_MoveRight] > 0 ||
b_Flags[b_Down] > 0 ||
b_Flags[b_TurnLeft] > 0 ||
b_Flags[b_TurnRight] > 0 ) &&
g_KeyPressTime > g_KeyTypematicDelay )
{
g_KeyPressTime -= g_KeyTypematicRate;
ProcessClick( true );
}
while ( g_GS.FGameTimeCount > g_GS.FUpdateSpeed )
{
if ( !MoveFigureDown() )
{
NextFigure();
}
检查行删除:
int Count = g_Field.deleteRegions( BlocksToDisappear );
…Update the game score here…
}
}
自动重复值是按照现代操作系统中开发人员通常使用的值来选择的:
const float g_KeyTypematicDelay = 0.2f; // 200 ms delay
const float g_KeyTypematicRate = 0.03f; // 33 Hz repeat rate
我们原始的 MultiBricks 游戏包含一个暂停按钮。你可以使用 第九章 编写图片谜题游戏 中描述的基于页面的用户界面作为练习来实现它。
另请参阅…
-
编写三消游戏
-
第九章 编写图片谜题游戏 中的 基于页面的用户界面 食谱
第九章:编写一个拼图游戏
在本章中,我们将涵盖:
-
实现拼图游戏逻辑
-
实现 3D 动画图像选择器
-
基于页面的用户界面
-
带有 Picasa 下载器的图像画廊
-
实现完整的拼图游戏
引言
在本章中,我们继续将前几章的食谱组合在一起。我们将实现一个拼图游戏,玩家需要将拼图块拼在一起以重现原始图像。图像是从 Picasa 照片托管特色画廊流式传输的,可以通过 3D 动画图像选择器进行挑选。我们的游戏有一个简单的基于页面的用户界面,可以作为更复杂游戏 UI 框架的起点。
注意
本章节的示例项目实际上是作者在 Google Play 上发布的 Linderdaum Puzzle HD 游戏的简化版本:play.google.com/store/apps/details?id=com.linderdaum.engine.puzzLHD
。
实现拼图游戏逻辑
本食谱展示了如何为拼图游戏实现游戏逻辑。游戏由一组矩形瓦片组成,这些瓦片在屏幕上随机排列并渲染。用户可以点击单个瓦片,并将它们移动,与其他瓦片交换位置。让我们草拟实现此逻辑的骨干数据结构。
准备开始
为了更好地感受游戏逻辑,你可以构建并运行2_PuzzleProto
项目,该项目可以从www.packtpub.com/support下载。如果你想享受功能完整的游戏,只需继续从 Google Play 下载我们的 Linderdaum Puzzle HD。你可以在这里下载:play.google.com/store/apps/details?id=com.linderdaum.engine.puzzLHD
。
如何操作...
-
首先,我们需要
clTile
类来存储单个拼图块的状态。它包含瓦片左上角的当前坐标、瓦片在网格中的原始索引以及此瓦片将要移动到的目标坐标:class clTile { public: int FOriginX, FOriginY; vec2 FCur, FTarget; LRect FRect; clTile(): FOriginX( 0 ), FOriginY( 0 ) {};
-
第二个构造函数计算并设置
FRect
字段,该字段包含稍后用于渲染的纹理坐标:clTile( int OriginX, int OriginY, int Columns, int Rows ): FOriginX( OriginX ) , FOriginY( OriginY ) {
-
计算瓦片的纹理坐标并将它们存储在
FRect
中:float TileWf = 1.0f / Columns, TileHf = 1.0f / Rows; float X1f = TileWf * ( OriginX + 0 ); float X2f = TileWf * ( OriginX + 1 ); float Y1f = TileHf * ( OriginY + 0 ); float Y2f = TileHf * ( OriginY + 1 ); FRect = LRect( X1f, Y1f, X2f, Y2f ); FTarget = FCur = vec2( OriginX, OriginY ); }
-
下两个方法设置目标和当前坐标:
void SetTarget( int X, int Y ) { FTarget = vec2( X, Y ); } void MoveTo( float X, float Y ) { FCur.x = X; FCur.y = Y; };
-
瓦片平滑地移动到目标坐标。我们使用时间计数器更新瓦片位置,并且每个时间步都重新计算坐标:
void Update( float dT ) { vec2 dS = FTarget - FCur; const float c_Epsilon = 0.001f; if ( fabs( dS.x ) < c_Epsilon ) { dS.x = 0; FCur.x = FTarget.x; } if ( fabs( dS.y ) < c_Epsilon ) { dS.y = 0; FCur.y = FTarget.y; } const float Speed = 10.0f; FCur += Speed * dT * dS; } };
-
游戏的状态由一个瓦片数组表示,该数组存储在
clPuzzle
类中:class clPuzzle { public: mutable std::vector<clTile> FTiles; int FColumns, FRows; bool FMovingImage; int FClickedI, FClickedJ; float FOfsX, FOfsY; clPuzzle() : FMovingImage( false ) , FClickedI( -1 ), FClickedJ( -1 ) , FOfsX( 0.0f ), FOfsY( 0.0f ) { Retoss( 4, 4 ); } ...
-
交换由它们的
(i,j)
二维坐标指定的两个瓦片:void SwapTiles( int i1, int j1, int i2, int j2 ) { std::swap( FTiles[j1 * FColumns + i1],FTiles[j2 * FColumns + i2] ); } };
-
如果所有的瓦片都在它们的位置上,游戏就完成了。为了检查瓦片是否在位,我们需要比较它的
FOriginX
和FOriginY
坐标与它当前的i
和j
坐标:bool clPuzzle::IsComplete() const { for ( int i = 0; i != FColumns; i++ ) { for ( int j = 0; j != FRows; j++ ) { clTile* T = GetTile( i, j ); if ( T->FOriginX != i || T->FOriginY != j) return false; } } return true; }
-
clPuzzle::Timer()
调用Update()
方法,该方法计算每个瓦片的新坐标。这样做的目的是当玩家松开触摸时,让瓦片返回到原来的位置:void clPuzzle::Timer( float DeltaSeconds ) { for ( int i = 0; i != FColumns; i++ ) { for ( int j = 0; j != FRows; j++ ) GetTile( i, j )->Update( DeltaSeconds ); } }
-
游戏的初始状态在
Retoss()
方法中生成:void Puzzle::Retoss(int W, int H) { FColumns = W; FRows = H; FTiles.resize( FColumns * FRows );
-
首先,我们在初始位置创建所有瓦片:
for ( int i = 0; i != FColumns; i++ ) for ( int j = 0; j != FRows; j++ ) FTiles[j * FColumns + i] =clTile( i, FRows - j - 1, FColumns, FRows );
-
然后,我们使用克努斯洗牌法,也称为费雪-耶茨洗牌法(
en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
),生成瓦片的随机排列:for ( int i = 0; i != FColumns; i++ ) { for ( int j = 0; j != FRows; j++ ) { int NewI = Math::RandomInRange( i, FColumns - 1 ); int NewJ = Math::RandomInRange( j, FRows - 1 ); SwapTiles( i, j, NewI, NewJ ); } } … }
-
用户输入的处理在
OnKey()
方法中完成。当用户按下鼠标按钮或轻触屏幕时,该方法会以KeyState
参数为真被调用。在鼠标释放或轻触结束时,OnKey()
方法会以KeyState
设置为假被调用。mx
和my
参数包含触摸的 2D 坐标。一旦触摸激活,我们存储瓦片的索引和触摸点相对于瓦片左上角的初始偏移量:void Puzzle::OnKey( float mx, float my, bool KeyState ) { int i = (int)floor( mx * FColumns ); int j = (int)floor( my * FRows ); int MouseI = ( i >= 0 && i < FColumns ) ? i : -1; int MouseJ = ( j >= 0 && j < FRows ) ? j : -1; FMovingImage = KeyState; if ( FMovingImage ) { FClickedI = MouseI; FClickedJ = MouseJ; if ( FClickedI >= 0&& FClickedJ >= 0&& FClickedI < FColumns&& FClickedJ < FRows ) { FOfsX = ( ( float )FClickedI / FColumns - mx ); FOfsY = ( ( float )FClickedJ / FRows - my ); } else { FClickedI = FClickedJ = -1; } } else
-
当触摸结束时,我们检查新瓦片位置的有效性,并用新位置中的瓦片交换选择的瓦片:
{ bool NewPosition = ( MouseI != FClickedI ||MouseJ != FClickedJ ); bool ValidPosition1 = ( FClickedI >= 0 && FClickedJ >=0 && FClickedI < FColumns && FClickedJ < FRows ); bool ValidPosition2 = ( MouseI >= 0 && MouseJ >= 0 &&MouseI < FColumns && MouseJ < FRows ); if ( NewPosition && ValidPosition1 && ValidPosition2 ) { int dX = MouseI - FClickedI; int dY = MouseJ - FClickedJ; SwapTiles( FClickedI, FClickedJ, MouseI, MouseJ ); } if ( IsComplete() ) { // TODO: We've got a winner! } FClickedI = FClickedJ = -1; } }
工作原理...
2_PuzzleProto
示例使用 clPuzzle
类来展示没有任何纹理或花哨图形的游戏玩法。
为了渲染拼图的状态,使用了以下例程:
void RenderGame( clPuzzle* g, const vec4& Color )
{
如果我们选择了瓦片,我们将其移动到新的鼠标或触摸位置:
if ( g->FMovingImage && g->FClickedI >= 0 &&g->FClickedI >= 0 &&g->FClickedI < g->FColumns &&g->FClickedJ < g->FRows )
{
vec2 MCI = Env_GetMouse();
int NewI = g->FClickedI;
int NewJ = g->FClickedJ;
float PosX, PosY;
PosX = Math::Clamp( MCI.x + g->FOfsX, 0.0f, 1.0f );
PosX *= g->FColumns;
PosY = Math::Clamp( MCI.y + g->FOfsY, 0.0f, 1.0f );
PosY *= g->FRows;
g->GetTile( NewI, NewJ )->MoveTo( PosX, PosY );
}
最后,每个瓦片通过调用 DrawTile()
方法进行渲染:
for ( int i = 0; i != g->FColumns; i++ )
for ( int j = 0; j != g->FRows; j++ )
DrawTile( g, i, j, Color );
}
DrawTile()
方法计算瓦片在标准化屏幕坐标 (0...1)
中的位置,并使用矩形顶点数组和 g_Canvas
对象来渲染 Tile
实例:
void DrawTile( clPuzzle* g, int i, int j, const vec4& Color )
{
if ( i < 0 || j < 0 || i >= g->FColumns || j >= g->FRows )
{ return; }
clTile* Tile = g->GetTile( i, j );
Tile->SetTarget( i, j );
float X = Tile->FCur.x;
float Y = Tile->FCur.y;
float TW = 1.0f / g->FColumns;
float TH = 1.0f / g->FRows;
vec4 TilePosition(TW * ( X + 0 ), TH * ( Y + 0 ),TW * ( X + 1 ), TH * ( Y + 1 ) );
g_Canvas->TexturedRectTiled(TilePosition, 1.0f, 1.0f, g_Texture,Effect, Color, VA, Tile->GetRect() );
}
在接下来的教程中,我们将这个简单的游戏玩法与动画图像选择器和 Picasa 图像下载器结合起来,创建一个功能更丰富的拼图游戏。
实现 3D 动画图像选择器
我们拼图游戏的主要 UI 元素是动画 3D 图像选择器。在本教程中,我们将向您展示如何渲染类似旋转木马的动画选择器并与用户互动。
准备就绪
在继续本教程之前,您可能需要回到第七章,跨平台 UI 和输入系统,了解 Canvas
类是如何工作的。为了更好地理解本教程中的代码,还需要一些数学知识。
如何操作...
渲染背后的想法非常简单。我们让各个四边形沿着四条引导曲线移动,使它们的角滑动。下图展示了同一个四边形在不同位置的一系列状态:
四条曲线展示了四边形角的路径。
-
我们从辅助类
Curve
开始,这个类实现了对控制点集的线性插值。曲线以参数形式表示。曲线的参数方程是通过将曲线上的点的坐标表示为参数的函数来表示这条曲线的方程。
参考文献:
en.wikipedia.org/wiki/Parametric_equation
class Curve { public: Curve() {}
-
AddControlPoint()
方法向曲线添加一个新的控制点。曲线是延迟计算的,现在我们只需存储指定的值:void AddControlPoint( float t, const vec3& Pos ) { T.push_back( t ); P.push_back( Pos ); }
-
GetPosition()
方法找到给定参数t
的段,并计算曲线上点的线性插值坐标:vec3 GetPosition( float t ) const { if ( t <= T[0] ) { return P[0]; } int N = (int)T.size(); int i = N - 1; for ( int s = 0 ; s < N - 1 ; s++ ) { if ( t > T[s] && t <= T[s + 1] ) { i = s; break; } } if ( i >= N - 1 ) { return P[N - 1]; } vec3 k = ( P[i + 1] - P[i] ) / ( T[i + 1] - T[i] ); return k * ( t - T[i] ) + P[i]; }
-
控制点和相应的参数存储在两个向量中:
std::vector<float> T; std::vector<vec3> P; };
-
3D 图像选择器的控制逻辑在
clFlowUI
类中实现:class clFlowUI: public iObject { public: clFlowUI( clPtr<clFlowFlinger> Flinger, int NumQuads ) { FFlinger = Flinger;
-
为我们的 UI 创建一个 3D 相机:
mtx4 RotationMatrix; RotationMatrix.FromPitchPanRoll( 0.0f, -90.0f, 0.0f ); FView = mtx4::GetTranslateMatrix(-vec3( 0.0f, -13.2f, 1.2f ) ) * RotationMatrix;
-
使用标准透视相机:
FProjection = Math::Perspective(45.0f, 1.33333f, 0.4f, 2000.0f ); float Y[] = { c_Height, c_Height, 0, 0 }; float Offs[] = { -c_PeakOffset, c_PeakOffset,c_PeakOffset, - c_PeakOffset }; float Coeff[] ={ c_Slope, - c_Slope, - c_Slope, c_Slope }; for ( int i = 0 ; i < 4 ; i++ ) { const int c_NumPoints = 100; for ( int j = - c_NumPoints / 2 ;j < c_NumPoints / 2 + 1 ; j++ ) { float t = ( float )j * c_PointStep; float P = Coef[i] * ( Ofs[i] - t );
-
余切乘以
exp(-x²)
:float Mult = c_FlowMult *exp( - c_FlowExp * P * P ); vec3 Pt( -t, Mult * c_Elevation *atan( P ) / M_PI, Y[i] ); FBaseCurve[i].AddControlPoint(t *exp( c_ControlExp * t * t ), Pt); } } …
-
使用当前元素数量更新 UI 滚动限制:
FFlinger->FMinValue = 0.0f; FFlinger->FMaxValue = c_OneImageSize *( ( float )FNumImg - 1.0f ); }
-
计算当前选定索引图像的索引:
int GetCurrentImage() const { return (int)ceilf( FFlinger->GetValue() / OneImageSize ); }
-
每个 四边形的坐标在
QuadCoords()
方法中计算,该方法为每条引导曲线调用Curve::GetPosition()
:virtual void QuadCoords( vec3* Pts, float t_center )const { float Offs[] ={ c_QuadSize, - c_QuadSize, - c_QuadSize, c_QuadSize }; for ( int i = 0 ; i < 4 ; i++ ) Pts[i] = FBaseCurve[i].GetPosition(t_center - Offs[i] / 2 ); }
-
为每条基本曲线添加轨迹控制点:
Curve FBaseCurve[4]; };
-
以下是引导曲线的参数。屏幕单位(在标准化坐标中)之间的连续控制点数量:
const float c_PointStep = 0.2f;
-
用于四边形顶点的经验调整参数,速度:
const float c_ControlExp = 0.001f;
-
图像的高度,即两条曲线之间的距离,厚度,以及曲线的斜率:
const float c_Height = 4.0f const float c_Elevation = 2.0f; const float c_Slope = 0.3f;
-
曲线峰值的对称位移,指数衰减,以及主系数:
const float c_PeakOffset = 3.0f; const float c_FlowExp = 0.01f; const float c_FlowMult = 4.0f;
-
clFlowFlinger
类保存选择器的动态状态:class clFlowFlinger: public iObject { public: clFlowFlinger() : FPressed( false ), FValue( 0.0f ), FVelocity( 0.0f ) {} virtual ~clFlowFlinger() {}
-
决定在选定时要做什么——如果选择完成则返回
true
,否则返回false
:virtual bool HandleSelection( float mx, float my ){ return false; }
-
更新动画和处理触摸:
void Update( float DeltaTime ); void OnTouch( bool KeyState ); … };
-
触摸处理在
OnTouch()
方法中执行:void clFlowFlinger::OnTouch( bool KeyState ) { int CurImg = ( int )ceilf( FValue / OneImageSize ); vec2 MousePt = Env_GetMouse(); double MouseTime = Env_GetMouseTime(); FPressed = KeyState; if ( KeyState ) { FClickPoint = FLastPoint = MousePt; FClickedTime = FLastTime = MouseTime; FInitVal = FValue; FVelocity = 0; } else {
-
如果触摸点移动距离少于屏幕的 1%,或者手势耗时少于 10 毫秒,我们认为这是一个点击:
double Time = MouseTime - FClickedTime; double c_TimeThreshold = 0.15; float c_LenThreshold = 0.01f; if ( ( FClickPoint - MousePt ).Length() <c_LenThreshold&& ( Time < c_TimeThreshold ) ) { HandleSelection( MousePt.x, MousePt.y ); FVelocity = 0; return; }
-
否则,如果手势持续时间少于 300 毫秒,我们停止运动:
float c_SpanThreshold = 0.3f; float dT = (float)( MouseTime - FLastTime ); float dSx = MousePt.x - FLastPoint.x; FVelocity = ( dT < c_SpanThreshold ) ?-AccelCoeff * dSx / dT : 0; } }
-
位置和时序的系数是根据运动的感知经验选择的。动态实现在
Update()
方法中:void clFlowFlinger::Update( float DeltaTime ) { float NewVal = 0.0f; if ( FPressed ) { vec2 CurPoint = Env_GetMouse(); NewVal = FInitVal; NewVal -= AccelCoef * ( CurPoint.x - FLastPoint.x ); } else { NewVal = FValue + FVelocity * DeltaTime; FVelocity -= FVelocity * c_Damping * DeltaTime;
-
当我们到达最后一个图像时,只需将位置固定在引导曲线上。为了获得流畅的体验,我们还通过使用线性公式插值位置添加了橡皮筋效果。
Damper
系数纯粹是经验性的:const float Damper = 4.5f * DeltaTime; if ( NewVal > FMaxValue ) { FVelocity = 0; NewVal = FMaxValue * Damper + NewVal * ( 1.0f - Damper ); } else if ( NewVal < FMinValue ) { FVelocity = 0; NewVal = FMinValue * Damper +NewVal * ( 1.0f - Damper ); } } FValue = NewVal; }
-
在
FlowFlinger.h
文件中定义了一组舒适的滚动参数:const float c_AccelCoeff = 15.0f; const float c_ValueGain = 0.1f; const float c_IntGain = 0.1f; const float c_DiffGain = 0.1f; const float c_Damping = 0.7f;
鼓励您尝试自己的值。
工作原理...
轮播渲染基于Canvas
,在RenderDirect()
函数中实现:
void RenderDirect( clPtr<clFlowFlinger> Control )
{
int Num = Control->FNumImg;
if ( Num < 1 ) { return; }
int CurImg = Control->GetCurrentImage();
float Dist = ( float )( Num * c_OneImageSize );
我们手动指定四边形的渲染顺序。首先渲染左侧的图像,然后是右侧的图像,最后是中央的图像:
int ImgOrder[] = {CurImg - 3, CurImg - 2, CurImg - 1,CurImg + 3, CurImg + 2, CurImg + 1,CurImg };
实际渲染时检查数组边界,并将Projection
和View
矩阵应用到四边形的每个角:
for ( int in_i = 0 ; in_i < 7 ; in_i++ )
{
int i = ImgOrder[in_i];
if ( i < 0 )
{ i += ( 1 - ( ( int )( i / Num ) ) ) * Num; }
if ( i >= Num )
{ i -= ( ( int )( i / Num ) ) * Num; }
if ( i < Num && i > -1 )
{
vec3 Pt[4];
Control->QuadCoords(Pt,Control->FFlinger->FValue - ( float )(i) *c_OneImageSize);
vec3 Q[4];
for(int j = 0 ; j < 4 ; j++)
Q[j] = Control->FProjection *Control->FView * Pt[j];
BoxR(Q, 0xFFFFFF);
}
}
}
最终的渲染是通过BoxR()
函数完成的,该函数实现在main.cpp
文件中。
为了支持选择功能,需要对轮播图代码进行修改。我们添加了GeomUtil.h
文件,其中包含了一些交线测试方法。类似于RenderFlow()
过程,我们遍历可见的图像,并对每个图像,从点击位置通过图像平面发射射线进行交点检测:
int clFlowUI::GetImageUnderCursor( float mx, float my ) const
{
if ( FNumImg < 1 ) { return -1; }
将 2D 屏幕触摸点映射到 3D 点和一个射线:
vec3 Pt, Dir;
MouseCoordsToWorldPointAndRay( FProjection, FView,mx, my, Pt, Dir );
int CurImg = GetCurrentImage();
int ImgOrder[] = { CurImg, CurImg - 1, CurImg + 1, CurImg - 2,CurImg + 2, CurImg - 3, CurImg + 3 };
遍历当前图像四边形:
for ( int cnt = 0 ; cnt < countof( ImgOrder ) ; cnt++ )
{
int i = ImgOrder[cnt];
if ( i < 0 || i >= (int)FNumImg ) { continue; }
将四边形坐标转换到世界空间:
vec3 Coords[4];
QuadCoords( Coords, FFlinger->GetValue() –( float )(i) * OneImageSize );
将射线与两个三角形相交:
vec3 ISect;
if ( IntersectRayToTriangle( Pt, Dir,Coords[0], Coords[1], Coords[2], ISect ) ||( IntersectRayToTriangle( Pt, Dir,Coords[0], Coords[2], Coords[3], ISect ) ) )
return i;
}
return -1;
}
Unproject()
和MouseCoordsToWorldPointAndRay()
函数将 2D 屏幕点坐标转换为 3D 世界空间中的射线,我们的轮播图四边形在其中飞行。它们的实现在GeomUtil.h
文件中可以找到。
为了将选择器回退到特定的图像,我们设置了一个目标位置:
void SetCurrentImageTarget( int i )
{ FFlinger->SetTargetValue( ( float )i * ( OneImageSize ) ); }
还有更多...
在这个示例中,我们使用了 3D 线来渲染轮播图。使用Canvas
类来渲染每个带纹理的四边形非常简单。我们也鼓励读者添加反射效果,这可以通过使用额外的变换来渲染相同的一组四边形,从而轻松实现水平面的反射效果。
另请参阅
- 实现完整的拼图游戏
基于页面的用户界面
在上一章中,我们开发了一个只包含单个页面的游戏。然而,大多数现代移动游戏都包含由复杂业务逻辑支持的复杂用户界面。典型的用户界面包括几个全屏页面和多个 UI 元素,如按钮、图像和输入框。这些使用游戏内渲染系统进行渲染,并不依赖于底层操作系统的用户界面。在本示例中,我们将展示如何解决这个问题。
准备就绪
你可能想要了解目前存在的开源 C++跨平台 UI 库。以下链接将帮助你:en.wikipedia.org/wiki/List_of_platform-independent_GUI_libraries
。
如果你想要为游戏提供完整的 HTML/CSS 用户界面,我们建议你看看libRocket (librocket.com
)。它的集成过程简单直接,但超出了本书的讨论范围。
如何操作...
-
单个页面处理所有按键、触摸、定时器和渲染事件:
class clGUIPage: public iObject { public: clGUIPage(): FFallbackPage( NULL ) {} virtual ~clGUIPage() {} virtual void Update(float DeltaTime) {} virtual void Render() {} virtual void SetActive();
-
处理基本的 UI 交互事件:
virtual bool OnKey( int Key, bool KeyState ); virtual void OnTouch( const LVector2& Pos, boolTouchState );
-
当点击BACK或ESC按钮时返回的页面:
clPtr<clGUIPage> FFallbackPage; … };
-
所有 UI 页面都由
clGUI
类管理,该类将所有事件委托给当前选中的页面:class clGUI: public iObject { public: clGUI(): FActivePage( NULL ), FPages() {} virtual ~clGUI() {} void AddPage(const clPtr<clGUIPage>& P) { P->FGUI = this; FPages.push_back(P); } void SetActivePage( const clPtr<clGUIPage>& Page ) { if ( Page == FActivePage ) { return; } FActivePage = Page; } void Update( float DeltaTime ) { if ( FActivePage ) FActivePage->Update( DeltaTime ); } void Render() { if ( FActivePage ) FActivePage->Render(); } void OnKey( vec2 MousePos, int Key, bool KeyState ) { FMousePosition = MousePos; if ( FActivePage ) FActivePage->OnKey( Key, KeyState ); } void OnTouch( const LVector2& Pos, bool TouchState ) { if ( FActivePage )FActivePage->OnTouch( Pos, TouchState ); } private: vec2 FMousePosition; clPtr<clGUIPage> FActivePage; std::vector< clPtr<clGUIPage> > FPages; };
-
页面本身作为
clGUIButton
对象的容器:class clGUIButton: public iObject { public: clGUIButton( const LRect& R, const std::string Title,clPtr<clGUIPage> Page ): FRect(R), FTitle(Title), FPressed(false), FFallbackPage(Page) {} virtual void Render(); virtual void OnTouch( const LVector2& Pos, boolTouchState );
-
这里最重要的一点是
clGUIButton
可以检测触摸点是否包含在按钮内部:virtual bool Contains( const LVector2& Pos ) { return FRect.ContainsPoint( Pos ); } public: LRect FRect; std::string FTitle; bool FPressed; clPtr<clGUIPage> FFallbackPage; };
这两个类足以构建我们游戏的简约交互式用户界面。
工作原理...
在设置用户界面时,我们构建页面并将它们添加到全局g_GUI
对象:
g_GUI = new clGUI();
clPtr<clGUIPage> Page_MainMenu = new clPage_MainMenu;
clPtr<clGUIPage> Page_Game = new clPage_Game;
clPtr<clGUIPage> Page_About = new clPage_About;
当点击BACK按钮时,页面回流如下所示:
Page_About → Page_MainMenu
Page_Game → Page_MainMenu
Page_MainMenu → exit the application
我们相应地设置返回导航目标页面的引用:
Page_Game->FFallbackPage = Page_MainMenu;
Page_About->FFallbackPage = Page_MainMenu;
g_GUI->AddPage( Page_MainMenu );
g_GUI->AddPage( Page_Game );
g_GUI->AddPage( Page_About );
主菜单页面还包含一些有用的按钮,可以帮助玩家在页面间导航:
Page_MainMenu->AddButton( new clGUIButton( LRect(0.3f, 0.1f, 0.7f, 0.3f ), "New Game", Page_Game ) );
Page_MainMenu->AddButton( new clGUIButton( LRect(0.3f, 0.4f, 0.7f, 0.6f ), "About", Page_About ) );
Page_MainMenu->AddButton( new clGUIButton( LRect(0.3f, 0.7f, 0.7f, 0.9f ), "Exit", NULL ) );
应用程序从主菜单页面开始:
g_GUI->SetActivePage( Page_MainMenu );
单个页面的实现非常直接。clPage_About
包含一些信息,我们只覆盖了Render()
方法:
class clPage_About: public clGUIPage
{
public:
virtual void Render()
{
…
}
};
主菜单页面包含三个按钮——一个用于退出应用程序,另一个用于开始游戏,还有一个用于进入关于页面:
class clPage_MainMenu: public clGUIPage
{
public:
OnKey()
方法还处理BACK和ESC按钮。由于我们的抽象层将这两个键转换成了单一的LK_ESCAPE
代码,所以我们使用单个检查:
virtual bool OnKey( int Key, bool KeyState )
{
if ( Key == LK_ESCAPE ) ExitApp();
return true;
}
…
};
游戏页面将渲染、触摸处理和计时事件重定向到全局g_Game
对象:
class clPage_Game: public clGUIPage
{
public:
virtual void OnTouch( const LVector2& Pos, bool TouchState )
{
g_Game.OnKey(Pos.x, Pos.y, TouchState);
clGUIPage::OnTouch(Pos, TouchState);
}
virtual void Update(float DT)
{
g_Game.Timer( DT );
}
virtual void Render()
{
RenderGame(&g_Game);
clGUIPage::Render();
}
};
还有更多内容...
作为练习,可以在这个简约框架中添加更多 UI 控件。添加静态文本标签和图片很容易。更复杂的 UI 控件,如输入框,也可以实现,但需要更多努力。如果你想要为你的游戏构建复杂的 UI,我们建议使用en.wikipedia.org/wiki/List_of_platform-independent_GUI_libraries
上的开源 C++ UI 库之一。
另请参阅
- 实现动画 3D 图片选择器
带有 Picasa 下载器的图片库
在这个教程中,我们将把 Picasa 图片下载器与基于旋转木马的 3D 图库集成,并在我们的游戏中将其用作图片选择页面。
如何操作...
-
为了下载图片并跟踪下载器的状态,我们使用描述任何游戏图片状态的
sImageDescriptor
结构:class sImageDescriptor: public iObject { public: size_t FID; std::string FURL;
现在是图片大小代码部分。我们仅支持一种图片类型:宽度为 256 像素的小预览图。游戏初次加载时可以通过网络加载非常小的图片,比如说不超过 128 像素,然后更大的 256 像素预览图替换它们,以便在 Full HD 屏幕上提供清晰的预览。当玩家从图库中选择了一张图片后,会从服务器获取全尺寸的预览图。
-
之前描述的方法正是我们在 Linderdaum Puzzle HD 游戏中采用的方法:
LPhotoSize FSize;
-
我们最初将此图片的当前状态设置为
L_NOTSTARTED
:LImageState FState; clPtr<clGLTexture> FTexture; clPtr<clBitmap> FNewBitmap; sImageDescriptor(): FState(L_NOTSTARTED),FSize(L_PHOTO_SIZE_256) { FTexture = new clGLTexture(); } void StartDownload( bool AsFullSize ); void ImageDownloaded( clPtr<Blob> Blob ); void UpdateTexture(); };
-
图片状态可以是以下之一:
enum LImageState { L_NOTSTARTED, // not started downloading L_LOADING, // download is in progress L_LOADED, // loading is finished L_ERROR // error occured };
-
下载完成后,我们使用
FreeImage
库从数据块中异步加载图片:void sImageDescriptor::ImageDownloaded( clPtr<clBlob> B ) { if ( !B ) { FState = L_ERROR; return; } clPtr<clImageLoadingCompleteCallback> CB =new clImageLoadingComplete( this ); clPtr<clImageLoadTask> LoadTask =new clImageLoadTask( B, 0, CB,g_Events.GetInternalPtr() ); g_Loader->AddTask( LoadTask ); }
-
异步加载很重要,因为图像解码可能相当慢,可能会干扰游戏用户体验。图像加载并转换为
clBitmap
后,我们应该更新纹理。纹理更新是在 OpenGL 渲染线程上同步完成的:void sImageDescriptor::UpdateTexture() { this->FState = L_LOADED; FTexture->LoadFromBitmap( FNewBitmap ); }
-
让我们上升一个层次,看看如何从服务器获取图像。图像集合是从网站检索并存储在
clGallery
对象中的:class clGallery: public iObject { public: clGallery(): FNoImagesList(true) {}
-
返回全尺寸图像的 URL:
std::string GetFullSizeURL(int Idx) const { return ( Idx < (int)FURLs.size() ) ?Picasa_GetDirectImageURL(FURLs[Idx], L_PHOTO_SIZE_ORIGINAL ): std::string(); } size_t GetTotalImages() const{ return FImages.size(); } clPtr<sImageDescriptor> GetImage( size_t Idx ) const{ return ( Idx < FImages.size() ) ? FImages[Idx] : NULL; } …
-
重新开始下载所有未加载的图像:
void ResetAllDownloads(); bool StartListDownload(); …
-
我们存储所有图像的基础 URL 以及图像本身:
std::vector<std::string> FURLs; std::vector< clPtr<sImageDescriptor> > FImages; };
-
要解码图像列表,我们使用来自 第三章,网络通信 的 Picasa 下载器代码:
class clListDownloadedCallback: public clDownloadCompleteCallback { public: clListDownloadedCallback( const clPtr<clGallery>& G ): FGallery(G) {} virtual void Invoke() { FGallery->ListDownloaded( FResult ); } clPtr<clGallery> FGallery; }; void clGallery::ListDownloaded( clPtr<clBlob> B ) { if ( !B ) { FNoImagesList = true; return; }
-
解析从 Picasa 加载的 XML 图像列表对应的数据块:
FURLs.clear(); void* Data = B->GetData(); size_t DataSize = B->GetSize(); Picasa_ParseXMLResponse(std::string( ( char* )Data, DataSize ), FURLs );
-
更新描述符并开始下载图像:
FImages.clear(); for ( size_t j = 0 ; j != FURLs.size() ; j++ ) { LPhotoSize Size = L_PHOTO_SIZE_256; std::string ImgUrl = Picasa_GetDirectImageURL(FURLs[j], Size); clPtr<sImageDescriptor> Desc = new sImageDescriptor(); Desc->FSize = Size; Desc->FURL = ImgUrl; Desc->FID = j; FImages.push_back(Desc); Desc->StartDownload( true ); } FNoImagesList = false; }
-
当图像加载完成后,该任务会向主线程分派一个
clBitmap::Load2DImage()
调用,以便更新 OpenGL 纹理:class clImageLoadTask: public iTask { public: … virtual void Run() { clPtr<ImageLoadTask> Guard( this ); clPtr<iIStream> In = (FSourceStream == NULL) ?g_FS->ReaderFromBlob( FSource ) : FSourceStream; FResult = new clBitmap(); FResult->Load2DImage(In, true); if ( FCallback ) { FCallback->FTaskID = GetTaskID(); FCallback->FResult = FResult; FCallback->FTask = this; FCallbackQueue->EnqueueCapsule( FCallback ); FCallback = NULL; } } … };
完整的源代码可以在 5_Puzzle
项目中找到。
工作原理…
下载是在全局 g_Downloader
对象中完成的,下载数据的实际解码是使用 FreeImage
库完成的。
另请参阅
- 第三章,网络通信
实现完整的图片拼图游戏
最后,我们手中有了所有部件,可以将它们组合成一个拼图游戏应用程序。
准备开始
构建并运行补充材料中的示例 5_Puzzle
。这个例子与本书中的其他例子一样,可以在 Android 和 Windows 上运行。
如何操作…
-
我们首先通过向
3_UIPrototype
项目添加一个新页面clPage_Gallery
来开始。这个页面将渲染和更新委托给全局g_Flow
对象:class clPage_Gallery: public clGUIPage { public: … virtual void Render() { RenderDirect( g_Flow ); } virtual void Update(float DT) { g_Flow->FFlinger->Update(DT); } private: void RenderDirect( clPtr<clFlowUI> Control ); };
-
RenderDirect()
方法实际上是对本章中 实现动画 3D 图像选择器 的RenderDirect()
方法的轻微修改版本。只有两个区别——我们用clCanvas::Rect3D()
调用(渲染一个带纹理的 3D 矩形)替换了线框四边形渲染,并使用本章最近描述的g_Gallery
对象中的纹理:void RenderDirect( clPtr<clFlowUI> Control ); { …
-
渲染顺序是从左到右,以防止图像重叠错误:
int ImgOrder[] = { CurImg - 3, CurImg - 2, CurImg - 1,CurImg + 3, CurImg + 2, CurImg + 1, CurImg };
-
根据预定义的顺序渲染七个带纹理的 3D 矩形。如果没有图像可用,我们使用占位符纹理
g_Texture
:for ( int in_i = 0 ; in_i < 7 ; in_i++ ) { … if ( i < Num && i > -1 ) { … clPtr<sImageDescriptor> Img =g_Gallery->GetImage( i ); clPtr<clGLTexture> Txt =Img ? Img->FTexture : g_Texture; g_Canvas->Rect3D( Control->FProjection,Control->FView, Pt[1], Pt[0], Pt[3], Pt[2], Txt,NULL ); } } }
-
一旦我们将用户界面分成多个页面,就可以将所有渲染、更新和输入委托给我们的
g_GUI
对象。引擎回调实现起来非常简单:void OnDrawFrame() { g_GUI->Render(); } void OnKey( int code, bool pressed ) { g_GUI->OnKey( g_Pos, code, pressed ); }
-
在定时器更新时,我们应该处理其他线程发布的事件:
void OnTimer( float Delta ) { g_Events->DemultiplexEvents(); g_GUI->Update( Delta ); }
-
点击处理要复杂一些,因为我们还需要额外存储图库标志。为了简单起见,我们将其实现为全局变量
g_InGallery
:void OnMouseDown( int btn, const LVector2& Pos ) { g_Pos = Pos; g_GUI->OnTouch( Pos, true ); if ( g_InGallery ) { g_MousePos = Pos; g_MouseTime = Env_GetSeconds(); g_Flow->FFlinger->OnTouch( true ); } }
回调OnMouseMove()
和OnMouseUp()
类似,可以在5_Puzzle/main.cpp
文件中找到。
它是如何工作的…
让我们简要地看一眼这个游戏。主菜单看起来如下面的截图所示:
点击新游戏会显示从 Picasa 获取的 3D 旋转木马图像,如下面的截图所示:
向左或向右滚动以选择所需的图片。点击它。游戏场地会以打乱的照片瓦片打开,如下面的截图所示:
移动瓦片以恢复原始图像。
还有更多...
以下是一些不错的特点,增加了拼图的可用性,你可以作为练习来实现:
-
实现不同的瓦片网格。4 x 4 的比较容易玩。8 x 14 的相当有挑战性。即使是更大的网格在 10 英寸的平板上也很好看。
-
将正确组装的瓦片缝合在一起,并将它们作为一个整体移动。
-
你可以使用洪泛填充算法来查找相邻的瓦片。
-
保存游戏状态,这样玩家就可以从上次离开的地方继续游戏。当有来电时保存游戏也是一个好主意。你可以在
OnStop()
回调中实现这一点。 -
多阶段预览——在 3D 旋转木马里加载小尺寸的低分辨率预览。一旦粗略预览加载完毕,获取更高分辨率的预览图像。当玩家点击他想玩的游戏图像时,下载高分辨率图像。这将使游戏在 Full HD 平板设备上看起来更加清晰。
-
实现不同的图库。你可以从 Flickr 开始,如第三章中的菜谱从 Flickr 和 Picasa 获取照片列表所述,网络通信。
另请参阅
-
第三章,网络通信
-
第四章,组织虚拟文件系统
-
第五章,跨平台音频流媒体
-
第六章,统一 OpenGL ES 3 和 OpenGL 3