精通安卓-NDK-全-
精通安卓 NDK(全)
原文:
zh.annas-archive.org/md5/F3DC9D6FA4DADE68301DCD4BEC565947
译者:飞龙
前言
本书是 2013 年 Packt Publishing 出版的《Android NDK 游戏开发手册》的续集。它从相当不寻常的角度涵盖了 NDK 开发:以可移植的方式构建你的移动 C++应用程序,以便它们可以在桌面计算机上开发和调试。这种方法大大减少了迭代和内容集成的时间,这对于专业移动软件开发领域至关重要。
本书涵盖的内容
第一章,使用命令行工具,指导你如何使用命令行安装和配置 Android 原生开发的基本工具,以及如何从零开始手动编写基本的 Android 应用程序配置文件,而无需依赖图形化 IDE。
第二章,原生库,向你展示如何构建流行的 C/C++库,并使用 Android NDK 将它们链接到你的应用程序中。这些库是实现图像、视频、声音和网络完全在 C++中丰富的功能应用程序的构建块。我们将展示如何编译库,当然也会给出一些关于如何开始使用它们的示例和提示。其中一些库在后续章节中会有更详细的讨论。
第三章,网络编程,重点关注如何从原生 C/C++代码处理网络相关功能。网络任务是异步的,就时间而言是不可预测的。即使底层连接是使用 TCP 协议建立的,也不能保证交付时间,且应用程序在等待数据时没有任何防冻措施。我们将详细探讨以可移植方式实现基本异步机制的方法。
第四章,组织虚拟文件系统,实现了低级别的抽象来处理与操作系统无关的文件和文件系统的访问。我们将展示如何在不依赖任何内置 API 的情况下,实现对.apk
文件中打包的 Android 资源的可移植和透明访问。在构建可在桌面环境中调试的多*台应用程序时,这种方法是必要的。
第五章, 跨*台音频流,基于 OpenAL 库为 Android 和桌面 PC 实现了一个真正可移植的音频子系统。代码使用了来自第三章,网络编程的多线程材料。
第六章,OpenGL ES 3.1 与跨*台渲染,专注于如何在 OpenGL 4 和 OpenGL ES 3 之上实现一个抽象层,以使我们的 C++图形应用程序能够在 Android 和桌面计算机上运行。
第七章,跨*台 UI 与输入系统,详细描述了一种渲染几何原语和 Unicode 文本的机制。章节的第二部分描述了一个多页图形用户界面,适合作为构建多*台应用程序界面的基石。这一章以一个 SDL 应用程序作为结尾,展示了我们 UI 系统在实际中的能力。
第八章,编写渲染引擎,将带你进入实际的渲染领域,并使用在第六章,OpenGL ES 3.1 与跨*台渲染中讨论的薄抽象层,来实现一个能够渲染从文件中加载的几何体,并使用材质、光线和阴影的 3D 渲染框架。
第九章,实现游戏逻辑,介绍了一种常见的组织游戏代码与程序用户界面部分交互的方法。这一章从 Boids 算法的实现开始,然后继续扩展我们在之前章节中实现的用户界面。
第十章,编写小行星游戏,继续将之前章节的材料整合在一起。我们将使用前几章介绍的技术和代码片段,实现一个具有 3D 图形、阴影、粒子和声音的小行星游戏。
你需要为这本书准备什么
本书假设你拥有一台基于 Windows 的 PC。由于模拟器在 3D 图形和原生音频方面的限制,建议使用 Android 智能手机或*板。
注意事项
本书中的源代码基于开源的 Linderdaum 引擎,并提炼了引擎中使用的一些方法和技巧。你可以在www.linderdaum.com
获取它。
假设你具备 C 或 C++的基础知识,包括指针操作、多线程和基本的面向对象编程概念。你应该熟悉高级编程概念,如线程和同步原语,并对 GCC 工具链有一定的基本了解。本书不涉及 Android Java 开发,你需要阅读其他资料来熟悉它。
对线性代数以及 3D 空间中的仿射变换有一定的了解将有助于理解 3D 图形相关的章节。
本书的目标读者
本书面向已经熟悉 Android NDK 基础知识的现有 Android 开发者,他们希望在使用 Android NDK 进行游戏开发方面获得专业知识。读者必须具有合理的 Android 应用程序开发经验。
约定
在这本书中,您会发现多种文本样式,用于区分不同类型的信息。以下是一些样式示例及其含义的解释。
文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理程序将如下显示:"编译 Android 静态库需要一组常规的Android.mk
和Application.mk
文件。"
代码块设置如下:
std::string ExtractExtension( const std::string& FileName )
{
size_t pos = FileName.find_last_of( '.' );
return ( pos == std::string::npos ) ?
FileName : FileName.substr( pos );
}
当我们希望引起您对代码块中某个特定部分的注意时,相关的行或项目会以粗体显示:
std::string ExtractExtension( const std::string& FileName )
{
size_t pos = FileName.find_last_of( '.' );
return ( pos == std::string::npos ) ?
FileName : FileName.substr( pos );
}
任何命令行输入或输出都如下编写:
>ndk-build
>ant debug
>adb install -r bin/App1-debug.apk
新术语和重要词汇以粗体显示。您在屏幕上看到的词,例如菜单或对话框中的,会在文本中以这样的形式出现:"检查打印到 Android 系统日志中的行Hello Android NDK!。"
注意
警告或重要注意事项会像这样出现在一个框中。
提示
技巧和窍门会像这样出现。
读者反馈
我们始终欢迎读者的反馈。让我们知道您对这本书的看法——您喜欢或可能不喜欢的内容。读者的反馈对我们开发您真正能充分利用的标题非常重要。
要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>
,并在邮件的主题中提及书名。
如果您在某个主题上有专业知识,并且有兴趣撰写或为书籍做贡献,请查看我们在www.packtpub.com/authors上的作者指南。
客户支持
既然您已经拥有了 Packt 的一本书,我们有许多方法可以帮助您充分利用您的购买。
下载示例代码
您可以从您的账户www.packtpub.com
下载您购买的所有 Packt 图书的示例代码文件。如果您在别处购买了这本书,可以访问www.packtpub.com/support
注册,我们会将文件直接通过电子邮件发送给您。源代码也可以从这个 GitHub 仓库地址github.com/corporateshark/Mastering-Android-NDK
获取。查看它以获取源代码的最新版本。
勘误
尽管我们已经竭尽全力确保内容的准确性,但错误仍然在所难免。如果您在我们的书中发现错误——可能是文本或代码中的错误——若您能向我们报告,我们将不胜感激。这样做可以避免其他读者产生困扰,并帮助我们改进本书后续版本。如果您发现任何勘误信息,请通过访问www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误信息得到验证,您的提交将被接受,勘误信息将会被上传到我们的网站,或添加到该标题勘误部分现有的勘误列表中。任何现有的勘误信息可以通过选择您的标题从www.packtpub.com/support
进行查看。
盗版问题
互联网上版权材料的盗版问题在所有媒体中持续存在。在 Packt,我们非常重视保护我们的版权和许可。如果您在任何形式下在互联网上发现我们作品非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
如发现疑似盗版材料,请通过 <copyright@packtpub.com>
联系我们,并提供相关链接。
我们感谢您帮助保护我们的作者,以及我们向您提供有价值内容的能力。
问题咨询
如果您在书的任何方面遇到问题,可以通过 <questions@packtpub.com>
联系我们,我们将尽力解决。
第一章:使用命令行工具
在本章中,我们将介绍主要与 Android 应用程序的创建和打包相关的命令行工具。我们将学习如何在 Microsoft Windows、Apple OS X 和 Ubuntu/Debian Linux 上安装和配置 Android NDK,以及如何在 Android 设备上构建和运行你的第一个本地应用程序。使用命令行工具构建项目对于使用 C++进行跨*台移动开发至关重要。
注意
本书基于 Android SDK 修订版 24.3.3 和 Android NDK r10e。源代码已使用 Android API 级别 23(Marshmallow)进行测试。
我们的主要关注点将是命令行为中心和*台无关的开发过程。
注意
Android Studio 是一个非常不错的新便携式开发 IDE,最*已更新至 1.4 版本。然而,它对 NDK 的支持仍然非常有限,本书将不对其进行讨论。
在 Windows 上使用 Android 命令行工具
要在 Microsoft Windows 环境中开始开发 Android 的原生 C++应用程序,你需要在系统上安装一些基本工具。
使用以下所需前提条件的列表开始为 Android 开发 NDK:
-
Android SDK:你可以在
developer.android.com/sdk/index.html
找到它。我们使用修订版 24。 -
Android NDK:你可以在
developer.android.com/tools/sdk/ndk/index.html
找到它。我们使用版本 r10e。 -
Java 开发工具包(JDK):你可以在
www.oracle.com/technetwork/java/javase/downloads/index.html
找到它。我们使用 Oracle JDK 版本 8。 -
Apache Ant:你可以在
ant.apache.org
找到它。这是用于构建 Java 应用程序的工具。 -
Gradle:你可以在
www.gradle.org
找到它。与 Ant 相比,这是一个更现代的 Java 构建自动化工具,能够管理外部依赖。
这些工具的当前版本在 Windows 上运行时无需使用任何中间兼容层;它们不再需要 Cygwin。
尽管这让我们感到痛苦,但 Android SDK 和 NDK 仍应安装到不包含空格的文件夹中。这是 Android SDK 内部构建脚本的限制;未加引号的环境变量内容会根据制表符、空格和新行字符分割成单词。
我们将把 Android SDK 安装到D:\android-sdk-windows
,Android NDK 安装到D:\ndk
,其他软件安装到它们的默认位置。
为了编译我们可移植的 C++代码以在 Windows 上运行,我们需要一个像样的工具链。我们推荐使用 Equation 软件包提供的最新版 MinGW,可在www.equation.com
获取。你可以根据需要选择 32 位或 64 位版本。
将所有工具放入各自的文件夹后,你需要设置环境变量以指向这些安装位置。JAVA_HOME
变量应指向 Java 开发工具包文件夹:
JAVA_HOME="D:\Program Files\Java\jdk1.8.0_25"
NDK_HOME
变量应指向 Android NDK 安装目录:
NDK_HOME=D:\NDK
ANDROID_HOME
应指向 Android SDK 文件夹:
ANDROID_HOME=D:\\android-sdk-windows
注意
注意最后一行中的双反斜杠。
NDK 和 SDK 将会不定期推出新版本,因此如果需要在文件夹名称中包含版本号,并按项目管理 NDK 文件夹可能会有帮助。
在 OS X 上使用 Android 命令行工具
在 OS X 上安装 Android 开发工具非常直接。首先,你需要从 developer.android.com/sdk/index.html
下载所需的官方 SDK 和 NDK 包。由于我们使用的是命令行工具,我们可以使用在 dl.google.com/android/android-sdk_r24.0.2-macosx.zip
可用的 SDK 工具包。至于 NDK,OS X Yosemite 可以使用 64 位 Android NDK,可以从 developer.android.com/tools/sdk/ndk/index.html
下载。
我们将所有这些工具安装到用户的 home 文件夹中;在我们的例子中,它是 /Users/sk
。
要获取 Apache Ant 和 Gradle,最好的方式是安装包管理器 Homebrew,访问 brew.sh
并使用以下命令安装所需的工具:
$ brew install ant
$ brew install gradle
这样你就不会被安装路径和其他低级配置问题所困扰。以下是安装包和设置路径的步骤:
注意
由于这本书的理念是通过命令行执行操作,我们确实会采取较为复杂的方式。不过,我们建议你实际上在浏览器中访问下载页面,developer.android.com/sdk/index.html
,检查 Android SDK 和 NDK 的更新版本。
-
从官方网站下载适用于 OS X 的 Android SDK 并将其放入你的 home 目录:
>curl -o android-sdk-macosx.zip http://dl.google.com/android/android-sdk_r24.0.2-macosx.zip
-
解压它:
>unzip android-sdk-macosx.zip
-
然后,下载 Android NDK。它是一个自解压的二进制文件:
>curl -o android-ndk-r10e.bin http://dl.google.com/android/ndk/android-ndk-r10e-darwin-x86_64.bin
-
因此,只需将其设置为可执行并运行:
>chmod +x android-ndk-r10e.bin >./android-ndk-r10e.bin
-
包已就位。现在,在你的 home 目录中的
.profile
文件中添加工具的路径以及所有必要的环境变量:export PATH=/Users/sk/android-ndk-r10e:/Users/sk/android-ndk-r10e/prebuilt/darwin-x86_64/bin:/Users/sk/android-sdk-macosx/platform-tools:$PATH
-
在 Android 脚本和工具中使用这些变量:
export NDK_ROOT="/Users/sk/android-ndk-r10e" export ANDROID_SDK_ROOT="/Users/sk/android-sdk-macosx"
-
编辑
local.properties
文件以按项目设置路径。
在 Linux 上使用 Android 命令行工具
在 Linux 上的安装与 OS X 一样简单。
注意
实际上,由于所有工具链和 Android 开源项目都基于 Linux 工具,Linux 开发环境确实是所有类型 Android 开发的原生环境。
在这里,我们仅指出一些不同之处。首先,我们不需要安装 Homebrew。只需使用可用的包管理器。在 Ubuntu 上,我们更愿意使用 apt
。以下是安装包以及设置 Linux 上的路径的步骤:
-
首先,我们来更新所有的
apt
包并安装默认的 Java 开发工具包:$ sudo apt-get update $ sudo apt-get install default-jdk
-
安装 Apache Ant 构建自动化工具:
$ sudo apt-get install ant
-
安装 Gradle:
$ sudo apt-get install gradle
-
从
developer.android.com/sdk/index.html
下载适合你 Linux 版本的官方 Android SDK,并将其解压到你的主目录下的一个文件夹中:$ wget http://dl.google.com/android/android-sdk_r24.0.2-linux.tgz $ tar –xvf android-sdk_r24.0.2-linux.tgz
-
下载适合你 Linux 系统(32 位或 64 位)的官方 NDK 包并运行它:
$ wget http://dl.google.com/android/ndk/android-ndk-r10e-linux-x86_64.bin $ chmod +x android-ndk-r10e-linux-x86_64.bin $ ./android-ndk-r10e-linux-x86_64.bin
该可执行文件将把 NDK 包的内容解压到当前目录。
-
现在,你可以设置环境变量以指向实际的文件夹:
NDK_ROOT=/path/to/ndk ANDROID_HOME=/path/to/sdk
注意
将环境变量定义添加到
/etc/profile
或/etc/environment
中很有用。这样,这些设置将适用于系统的所有用户。
手动创建基于 Ant 的应用程序模板
让我们从最低级别开始,创建一个可使用 Apache Ant 构建的应用程序模板。每个要使用 Apache Ant 构建的应用程序都应包含预定义的目录结构和配置 .xml
文件。这通常使用 Android SDK 工具和 IDE 完成。我们将解释如何手动完成,以让你了解幕后的机制。
提示
下载示例代码
你可以从 www.packtpub.com
的账户下载你购买的所有 Packt Publishing 书籍的示例代码文件。如果你在其他地方购买了这本书,可以访问 www.packtpub.com/support
并注册,我们会直接将文件通过电子邮件发送给你。
对于这本书,源代码文件也可以从以下 GitHub 仓库下载或派生:github.com/corporateshark/Mastering-Android-NDK
我们最小化项目的目录结构如下截图所示(完整的源代码请参见源代码包):
我们需要在此目录结构中创建以下文件:
-
res/drawable/icon.png
-
res/values/strings.xml
-
src/com/packtpub/ndkmastering/App1Activity.java
-
AndroidManifest.xml
-
build.xml
-
project.properties
图标 icon.png
应该在那里,目前包含一个安卓应用程序的示例图像:
文件strings.xml
是使用 Android 本地化系统所必需的。在AndroidManifest.xml
清单文件中,我们使用字符串参数app_name
而不是实际的应用程序名称。文件strings.xml
将此参数解析为人类可读的字符串:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">AntApp1</string>
</resources>
最小可构建应用程序的 Java 源代码在App1Activity.java
文件中:
package com.packtpub.ndkmastering;
import android.app.Activity;
public class App1Activity extends Activity
{
};
其他三个文件AndroidManifest.xml
、build.xml
和project.properties
,包含了 Ant 构建项目所需的描述。
清单文件AndroidManifest.xml
如下所示:
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.packtpub.ndkmastering"
android:versionCode="1"
android:versionName="1.0.0">
我们的应用程序将需要 Android 4.4(API 级别 19),并且已经在 Android 6.0(API 级别 23)上进行了测试:
<uses-sdk android:minSdkVersion="19" android:targetSdkVersion="23" />
本书中的大多数示例将需要 OpenGL ES 3。在此提及一下:
<uses-feature android:glEsVersion="0x00030000"/>
<application android:label="@string/app_name"
android:icon="@drawable/icon"
android:installLocation="preferExternal"
android:largeHeap="true"
android:allowBackup="true">
这是主活动的名称:
<activity android:name="com.packtpub.ndkmastering.App1Activity"
android:launchMode="singleTask"
我们希望应用程序在全屏模式下,且为横屏方向:
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
android:screenOrientation="landscape"
我们的应用程序可以从系统启动器中启动。应用程序的可显示名称存储在app_name
参数中:
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>
注意
你可以在developer.android.com/guide/topics/manifest/manifest-intro.html
阅读官方关于应用程序清单的 Google 文档。
文件build.xml
要简单得多,主要与 Android 工具生成的类似:
<?xml version="1.0" encoding="UTF-8"?>
<project name="App1" default="help">
<loadproperties srcFile="project.properties" />
<fail message="sdk.dir is missing. Make sure to generate local.properties using 'android update project' or to inject it through an env var"
unless="sdk.dir"/>
<import file="${sdk.dir}/tools/ant/build.xml" />
</project>
与 Android SDK Tools 相比,这里我们没有使用ant.properties
。这样做只是为了简单起见,仅具有教育目的。
文件project.properties
同样包含特定*台的声明,情况类似:
target=android-19
sdk.dir=d:/android-sdk-windows
现在,我们的第一个应用程序(甚至还没有包含任何本地代码)已经准备好构建了。使用以下命令行构建它:
$ ant debug
如果一切操作都正确,你应该会看到类似于以下的输出尾部:
要从命令行安装.apk
文件,请运行adb install -r bin/App1-debug.apk
以将新构建的.apk
安装到你的设备上。从启动器(AntApp1)启动应用程序,并享受黑色的屏幕。你可以使用BACK键退出应用程序。
手动创建基于 Gradle 的应用程序模板
相比于 Ant,Gradle 是一个更加多功能的 Java 构建工具,它能轻松地处理外部依赖和仓库。
注意
我们建议在继续使用 Gradle 之前,观看 Google 提供的www.youtube.com/watch?v=LCJAgPkpmR0
这个视频,并阅读官方的命令行构建手册developer.android.com/tools/building/building-cmdline.html
。
*期的 Android SDK 版本与 Gradle 紧密集成,Android Studio 就是使用它作为其构建系统的。让我们扩展之前的1_AntApp
应用程序,使其能够用 Gradle 构建。
首先,进入项目的根目录,并创建一个包含以下内容的build.gradle
文件:
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:1.0.0'
}
}
apply plugin: 'com.android.application'
android {
buildToolsVersion "19.1.0"
compileSdkVersion 19
sourceSets {
main {
manifest.srcFile 'AndroidManifest.xml'
java.srcDirs = ['src']
resources.srcDirs = ['src']
aidl.srcDirs = ['src']
renderscript.srcDirs = ['src']
res.srcDirs = ['res']
assets.srcDirs = ['assets']
}
}
lintOptions {
abortOnError false
}
}
完成后,运行命令gradle init
。输出结果应类似于以下内容:
>gradle init
:init
The build file 'build.gradle' already exists. Skipping build initialization.
:init SKIPPED
BUILD SUCCESSFUL
Total time: 5.271 secs
当前文件夹中将创建.gradle
子文件夹。现在,运行以下命令:
>gradle build
输出的末尾应如下所示:
:packageRelease
:assembleRelease
:assemble
:compileLint
:lint
Ran lint on variant release: 1 issues found
Ran lint on variant debug: 1 issues found
Wrote HTML report to file:/F:/Book_MasteringNDK/Sources/Chapter1/2_GradleApp/build/outputs/lint-results.html
Wrote XML report to F:\Book_MasteringNDK\Sources\Chapter1\2_GradleApp\build\outputs\lint-results.xml
:check
:build
BUILD SUCCESSFUL
Total time: 9.993 secs
生成的.apk
包可以在build\outputs\apk
文件夹中找到。尝试在您的设备上安装并运行2_GradleApp-debug.apk
。
嵌入本地代码
让我们继续这本书的主题,为我们的模板应用程序编写一些本地 C++代码。我们将从包含单个函数定义的jni/Wrappers.cpp
文件开始:
#include <stdlib.h>
#include <jni.h>
#include <android/log.h>
#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "NDKApp", __VA_ARGS__))
extern "C"
{
JNIEXPORT void JNICALL Java_com_packtpub_ndkmastering_AppActivity_onCreateNative( JNIEnv* env, jobject obj )
{
LOGI( "Hello Android NDK!" );
}
}
这个函数将通过 JNI 机制从 Java 中调用。如下更新AppActivity.java
:
package com.packtpub.ndkmastering;
import android.app.Activity;
import android.os.Bundle;
public class AppActivity extends Activity
{
static
{
System.loadLibrary( "NativeLib" );
}
@Override protected void onCreate( Bundle icicle )
{
super.onCreate( icicle );
onCreateNative();
}
public static native void onCreateNative();
};
现在,我们需要将这段代码构建成一个可安装的.apk
包。为此我们需要几个配置文件。第一个是jni/Application.mk
,它包含*台和工具链信息:
APP_OPTIM := release
APP_PLATFORM := android-19
APP_STL := gnustl_static
APP_CPPFLAGS += -frtti
APP_CPPFLAGS += -fexceptions
APP_CPPFLAGS += -DANDROID
APP_ABI := armeabi-v7a-hard
APP_MODULES := NativeLib
NDK_TOOLCHAIN_VERSION := clang
我们使用最新版本的 Clang 编译器——即在我们编写这些内容时的 3.6 版本,以及armeabi-v7a-hard
目标,它支持硬件浮点计算和通过硬件浮点寄存器传递函数参数,从而实现更快的代码。
第二个配置文件是jni/Android.mk
,它指定了我们想要编译的.cpp
文件以及应使用的编译器选项:
TARGET_PLATFORM := android-19
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := NativeLib
LOCAL_SRC_FILES += Wrappers.cpp
LOCAL_ARM_MODE := arm
COMMON_CFLAGS := -Werror -DANDROID -DDISABLE_IMPORTGL
ifeq ($(TARGET_ARCH),x86)
LOCAL_CFLAGS := $(COMMON_CFLAGS)
else
LOCAL_CFLAGS := -mfpu=vfp -mfloat-abi=hard -mhard-float -fno-short-enums -D_NDK_MATH_NO_SOFTFP=1 $(COMMON_CFLAGS)
endif
LOCAL_LDLIBS := -llog -lGLESv2 -Wl,-s
LOCAL_CPPFLAGS += -std=gnu++11
include $(BUILD_SHARED_LIBRARY)
在这里,我们链接到 OpenGL ES 2,为非 x86 目标启用硬件浮点数的编译器开关,并列出所需的.cpp
源文件。
使用以下命令从项目根目录构建本地代码:
>ndk-build
输出结果应如下所示:
>ndk-build
[armeabi-v7a-hard] Compile++ arm : NativeLib <= Wrappers.cpp
[armeabi-v7a-hard] SharedLibrary : libNativeLib.so
[armeabi-v7a-hard] Install : libNativeLib.so => libs/armeabi-v7a/libNativeLib.so
最后,我们需要告诉 Gradle,我们希望将生成的本地库打包进.apk
。编辑build.gradle
文件,在sourceSets
的main
部分添加以下行:
jniLibs.srcDirs = ['libs']
现在,如果我们运行命令gradle build
,生成的包build\outputs\apk\3_NDK-debug.apk
将包含所需的libNativeLib.so
文件。您可以像往常一样安装并运行它。使用adb logcat
检查 Android 系统日志中打印的Hello Android NDK!这一行。
注意
那些不想在这样的小项目中处理 Gradle 的人可以使用古老的 Apache Ant。只需运行命令ant debug
即可实现。这种方式不需要额外的配置文件将共享的 C++库放入.apk
。
构建并签署发布版的 Android 应用
我们已经学习了如何使用命令行创建带有本地代码的 Android 应用。让我们在命令行工具的话题上画上圆满的句号,学习如何准备并签署应用程序的发布版本。
关于在 Android 上签名过程的详细解释,可以在开发者手册中找到,地址是 developer.android.com/tools/publishing/app-signing.html
。让我们使用 Ant 和 Gradle 来完成签名。
首先,我们需要重新构建项目并创建 .apk
包的发布版本。让我们用 3_NDK
项目来做这件事。我们使用以下命令调用 ndk-build
和 Apache Ant:
>ndk-build
>ant release
Ant 输出的末尾如下所示:
-release-nosign:
[echo] No key.store and key.alias properties found in build.properties.
[echo] Please sign F:\Book_MasteringNDK\Sources\Chapter1\3_NDK\bin\App1-release-unsigned.apk manually
[echo] and run zipalign from the Android SDK tools.
[propertyfile] Updating property file: F:\Book_MasteringNDK\Sources\Chapter1\3_NDK\bin\build.prop
[propertyfile] Updating property file: F:\Book_MasteringNDK\Sources\Chapter1\3_NDK\bin\build.prop
[propertyfile] Updating property file: F:\Book_MasteringNDK\Sources\Chapter1\3_NDK\bin\build.prop
[propertyfile] Updating property file: F:\Book_MasteringNDK\Sources\Chapter1\3_NDK\bin\build.prop
-release-sign:
-post-build:
release:
BUILD SUCCESSFUL
Total time: 2 seconds
让我们用 Gradle 做同样的事情。也许您已经注意到,当我们运行 gradle build 时,build/outputs/apk
文件夹中有一个 3_NDK-release-unsigned.apk
文件。这正是我们所需要的。这将是我们签名过程的原材料。
现在,我们需要一个有效的发布密钥。我们可以使用 Java 开发工具包中的 keytool
创建自签名的发布密钥,使用以下命令:
$ keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
这将要求我们填写创建 release key
和 keystore
时所需的所有字段。
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]
现在,我们准备进行实际的 .apk
包签名。使用 Java 开发工具包中的 jarsigner
工具来完成这个操作:
>jarsigner -verbose -sigalg MD5withRSA -digestalg SHA1 -keystore my-release-key.keystore 3_NDK-release-unsigned.apk alias_name
这个命令是交互式的,它将要求用户输入 keystore
和 key passwords
。但是,我们可以以下面的方式将这两个密码作为参数提供给这个命令:
>jarsigner -verbose -sigalg MD5withRSA -digestalg SHA1 -keystore my-release-key.keystore -storepass 123456 –keypass 123456 3_NDK-release-unsigned.apk alias_name
当然,密码应与您在创建 release key
和 keystore
时输入的密码相匹配。
在我们能够安全地在 Google Play 上发布 .apk
包之前,还有一件重要的事情要做。Android 应用程序可以使用内存映射文件和 mmap()
系统调用来访问 .apk
中的未压缩内容,但 mmap()
可能会对底层数据施加一些对齐限制。我们需要将 .apk
中的所有未压缩数据按照 4 字节边界对齐。Android SDK 有 zipalign
工具来完成这个操作,如下面的命令所示:
>zipalign -v 4 3_NDK-release-unsigned.apk 3_NDK-release.apk
现在,我们的 .apk
已准备好在 Google Play 上发布。
组织跨*台代码
本书延续了我们之前出版的《Android NDK 游戏开发手册, Packt Publishing> 的思想:即使用“所见即所得”原则进行跨*台开发的可能。大部分应用程序逻辑可以在熟悉的桌面环境如 Windows 中开发并测试,手头拥有所有必要的工具,必要时可以构建为 Android 使用 NDK。
为了组织和维护跨*台的 C++ 源代码,我们需要将所有内容分为*台特定和*台独立部分。我们的 Android 特定本地代码将存储在项目的 jni
子文件夹中,这与我们之前的简约示例完全相同。共享的*台独立 C++ 代码将放入 src-native
子文件夹。
使用 TeamCity 持续集成服务器与 Android 应用程序
TeamCity 是一个强大的持续集成和部署服务器,可用于自动化你的 Android 应用构建。这可以在 www.jetbrains.com/teamcity
找到。
注意
TeamCity 对最多需要 20 个构建配置和 3 个构建代理的小型项目是免费的,对于开源项目则是完全免费的。在 www.jetbrains.com/teamcity/buy
申请开源许可。
服务器安装过程非常直接。Windows、OS X 或 Linux 机器可以作为服务器或构建代理。这里,我们将展示如何在 Windows 上安装 TeamCity。
从 www.jetbrains.com/teamcity/download
下载最新版本的安装程序,并使用以下命令运行它:
>TeamCity-9.0.1.exe
安装所有组件并将其作为 Windows 服务 运行。为了简单起见,我们将在一台机器上同时运行服务器和代理,如下面的屏幕截图所示:
选择所需的 TeamCity 服务器端口。我们将使用默认的 HTTP 端口 80。在 SYSTEM
账户下运行 TeamCity 服务器 和 代理 服务。
一旦服务器上线,打开你的浏览器并通过地址 http://localhost
连接到它。创建一个新项目和构建配置。
注意
要使用 TeamCity,你应该将你的项目源代码放入版本控制系统。Git 和 GitHub 将是一个不错的选择。
如果你的项目已经在 GitHub 上,你可以创建一个指向你的 GitHub 仓库 URL 的 Git 版本控制系统根目录,如下所示 https://github.com/<你的登录名>/<你的项目>.git
。
添加一个新的命令行构建步骤并输入脚本的内容:
ndk-build
ant release
你也可以在这里添加使用 jarsigner
的签名,并使用 zipalign
工具创建最终的 .apk
生产文件。
现在,进入 通用设置 步骤并将工件路径添加到 bin/3_NDK-release.apk
。项目已准备好进行持续集成。
概括
在本章中,我们学习了如何使用命令行安装和配置 Android 原生开发的基本工具,以及如何不依赖图形 IDE 而手动编写 Android 应用基本配置文件。在后续章节中,我们将练习这些技能并构建一些项目。
第二章:本地库
在本章中,你将学习如何使用 Android NDK 构建流行的 C/C++库,并将它们链接到你的应用程序中。这些库是实现图像、视频、声音、物理模拟和完全在 C++中网络功能的丰富功能应用程序的构建块。我们将提供最小示例来演示每个库的功能。音频和网络库将在后续章节中详细讨论。我们将向你展示如何编译库,当然也会提供一些简短的示例和如何开始使用它们的提示。
在不同处理器和操作系统间移植库的典型陷阱包括内存访问(结构对齐和填充)、字节序(大小端)、调用约定和浮点问题。前面章节中描述的所有库都很好地解决了这些问题,即使其中一些库没有正式支持 Android NDK,修复这些问题也只是几个编译器开关的问题。
为了构建上述库,我们需要为 Windows、Linux 和 OS X 创建 makefile,并为 NDK 创建一对Android.mk/Application.mk
文件。库的源文件被编译成目标文件。一系列目标文件组合成一个档案,这也称为静态库。之后,这个静态库可以作为链接器的输入。我们从桌面版本开始,首先为 Windows。
为了构建特定于 Windows 的库版本,我们需要一个 C++编译器。我们将使用来自 MinGW 的 GCC 工具链,该工具链在第一章,使用命令行工具中描述。对于每个库,我们有一系列源代码文件,我们需要得到静态库,一个带有.a
扩展名的文件。
处理预编译的静态库
将我们需要的库在不同*台构建的源代码放入src
目录中。Makefile 脚本应该如下开始:
CFLAGS = -O2 -I src
这行定义了一个变量CFLAGS
,其中包含编译器命令行参数的列表。在我们的例子中,我们指示编译器在src
目录中查找头文件。如果库源代码跨越多个目录,我们需要为每个目录添加–I
开关。-O2
开关告诉编译器启用 2 级优化。接下来,我们为每个源文件添加以下行:
<SourceFileName>.o:
gcc $(CFLAGS) –c <SourceFile>.cpp –o <SourceFile>.o
字符串<SourceFileName>
应该被替换为实际的.cpp
源文件名,并且这些行应该针对每个源文件编写。
现在,我们添加目标文件列表:
ObjectFiles = <SourceFile1>.o <SourceFile2>.o
最后,我们将编写我们库的目标:
<LibraryName>:
ar –rvs <LibraryName>.a $(ObjectList)
Makefile 脚本中除了空行和目标名称以外的每一行都应该以制表符开头。要构建库,请调用以下命令:
>make <LibraryName>.a
当在我们的程序中使用库时,我们将LibraryName.a
文件作为参数传递给gcc
。
Makefile 由类似于编程语言中子例程的目标组成,通常每个目标都会生成一个目标文件。例如,我们已经看到,库的每个源文件都编译成相应的目标文件。
目标名称可能包括文件名模式以避免复制粘贴,但在最简单的情况下,我们只需列出所有源文件,并复制这些行,将SourceFileName
字符串替换为适当的文件名。gcc
命令后的–c
开关是编译源文件的选项,而–o
指定输出目标文件的名字。$(CFLAGS)
符号表示将CFLAGS
变量的值代入命令行。
Windows 的 GCC 工具链包括ar
工具,它是归档器的缩写。我们库的 Makefile 调用此工具来创建库的静态版本。这将在 Makefile 脚本的最后几行完成。
当带有目标文件列表的一行变得过长时,可以使用反斜杠符号将其分成多行,如下所示:
ObjectFileList = FileName1.o \
... \
FileNameN.o
反斜杠后面不应该有空白,因为这是make
工具的限制。make
工具是可移植的,因此同样的规则精确适用于我们使用的所有桌面操作系统:Windows、Linux 和 OS X。
现在,我们能够使用 Makefiles 和命令行构建大多数库。让我们为 Android 构建它们。首先,创建一个名为jni
的文件夹,并创建一个jni/Application.mk
文件,其中包含适当的编译器开关并相应地设置库的名称。例如,Theora 库的一个应该如下所示:
APP_OPTIM := release
APP_PLATFORM := android-19
APP_STL := gnustl_static
APP_CPPFLAGS += -frtti
APP_CPPFLAGS += -fexceptions
APP_CPPFLAGS += -DANDROID
APP_ABI := armeabi-v7a-hard
APP_MODULES := Theora
NDK_TOOLCHAIN_VERSION := clang
注意
在这里,我们将使用armeabi-v7a-hard
作为支持最广泛的现代 ABI 之一。Android NDK 支持许多其他架构和 CPU。请参考 NDK 程序员指南以获取完整且最新的列表。
它将使用安装的 NDK 中可用的最新版本的 Clang 编译器。jni/Android.mk
文件与我们之前章节为3_NDK
示例应用程序编写的文件类似,但有一些例外。在文件顶部,必须定义一些必要的变量。让我们看看 OpenAL-Soft 库的Android.mk
文件可能的样子:
TARGET_PLATFORM := android-19
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_ARM_MODE := arm
LOCAL_MODULE := OpenAL
LOCAL_C_INCLUDES += src
LOCAL_SRC_FILES += <ListOfSourceFiles>
定义一些常见的编译器选项:将所有警告视为错误(-Werror
),定义ANDROID
预处理符号:
COMMON_CFLAGS := -Werror -DANDROID
编译标志根据选定的 CPU 架构定义:
ifeq ($(TARGET_ARCH),x86)
LOCAL_CFLAGS := $(COMMON_CFLAGS)
else
LOCAL_CFLAGS := -mfpu=vfp -mfloat-abi=hard -mhard-float -fno-short-enums -D_NDK_MATH_NO_SOFTFP=1 $(COMMON_CFLAGS)
endif
在我们所有的示例中,我们将使用硬件浮点 ABI armeabi-v7a-hard
,因此让我们相应地构建库。
注意
armeabi-v7a-hard 和 armeabi-v7a 之间的主要区别在于,硬件浮点 ABI 在 FPU 寄存器内部传递浮点函数参数。在浮点密集型应用程序中,这可以显著提高代码的性能,其中浮点值在不同的函数之间传递。
由于我们正在构建静态库,我们需要在Android.mk
文件末尾添加以下行:
include $(BUILD_STATIC_LIBRARY)
现在构建静态库只需调用一次ndk-build
脚本。在对动态链接和 Windows *台做一点简短的说明之后,我们继续编译实际的库。
在 Windows *台上的动态链接
本章考虑的库可以作为 Windows 的动态链接库进行构建。我们不提供这样做的方法,因为每个项目已经包含了所有必要的说明,而且 Windows 开发不是本书的重点。唯一的例外是 libcurl 和 OpenSSL 库。我们建议您从官方库网站下载预构建的 DLL 文件。
在 FreeImage、FreeType 和 Theora 的示例代码中,我们使用函数指针,这些指针使用 WinAPI 的GetProcAddress()
和LoadLibrary()
函数进行初始化。在 Android 上使用相同的函数指针,但在这种情况下,它们指向静态库中的相应函数。
例如,函数FreeImage_OpenMemory()
声明如下:
typedef FIMEMORY* ( DLL_CALLCONV* PFNFreeImage_OpenMemory )
( void*, unsigned int );
PFNFreeImage_OpenMemory FI_OpenMemory = nullptr;
在 Windows 上,我们使用GetProcAddress()
调用来初始化指针:
FI_OpenMemory = (PFNFreeImage_OpenMemory)
GetProcAddress (hFreeImageDLL,"FreeImage_OpenMemory");
在 Android、OSX 和 Linux 上,这是一个重定向:
FI_OpenMemory = &FreeImage_OpenMemory;
示例代码仅引用了FI_OpenMemory()
,因此对于 Android 和 Windows 来说是一样的。
Curl
libcurl 库curl.haxx.se/libcurl
是一个免费且易于使用的客户端 URL 传输库。它是处理众多网络协议的本机应用程序的实际标准。Linux 和 OS X 用户可以在他们的系统上享受这个库,并且可以使用-lcurl
开关与之链接。在 Windows 主机上为 Android 编译 libcurl 需要执行一些额外的步骤,我们在这里解释这些步骤。
libcurl 库的构建过程基于autoconf
;在实际构建库之前,我们需要生成curl_config.h
文件。从包含未打包的 libcurl 发行包的文件夹中运行配置脚本。交叉编译命令行标志应设置为:
--host=arm-linux CC=arm-eabi-gcc
CPPFLAGS
变量的-I
参数应指向 NDK 文件夹中的/system/core/include
子文件夹,在我们的例子中:
CPPFLAGS="-I D:/NDK/system/core/include"
libcurl 库可以通过多种方式进行定制。我们使用以下这组参数(除了 HTTP 和 HTTPS 之外禁用所有协议):
>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 --with-ssl
--with-ssl
参数允许使用 OpenSSL 库来提供安全的 HTTPS 传输。这个库将在本章进一步讨论。然而,为了处理 SSL 加密连接,我们需要告诉 libcurl 我们的系统证书位于何处。这可以在curl_config.h
文件开头通过定义CURL_CA_BUNDLE
来完成:
#define CURL_CA_BUNDLE "/etc/ssl/certs/ca-certificates.crt"
配置脚本将生成一个有效的curl_config.h
头文件。你可以在书的源代码包中找到它。编译 Android 静态库需要一个通常的Android.mk
和Application.mk
文件集,这也包含在1_Curl
示例中。在下一章,我们将学习如何使用 libcurl 库通过 HTTPS 从互联网下载实际内容。然而,以下是一个简化使用示例来检索 HTTP 页面:
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* )
{
if ( !P ) return 0;
printf( "%s\n", P );
}
检索到的数据将在你的桌面应用程序上显示在屏幕上。同样的代码在 Android 中会像哑巴一样工作,不会产生任何可见的副作用,因为printf()
函数在那里只是一个占位符。
OpenSSL
OpenSSL 是一个开源库,实现了安全套接字层(SSL v2/v3)和传输层安全(TLS)协议,以及一个功能强大的通用加密库。可以在www.openssl.org
找到它。
在这里,我们将构建 OpenSSL 版本 1.0.1j,其中包含对 Heartbleed 漏洞的修复(heartbleed.com
)。
Heartbleed 漏洞是流行的 OpenSSL 加密软件库中一个严重的安全漏洞。这个弱点使得在正常情况下受 SSL/TLS 加密保护的信息可以被窃取,而这种加密被用于确保互联网的安全。
如果你尝试将应用程序静态链接到一个旧版本的 OpenSSL,并在 Google Play 上发布,你可能会看到以下安全警报:
到这本书出版时,即使是 OpenSSL 的 1.0.0j 版本也可能已经过时了。因此,下载最新的源代码并相应地更新 NDK Makefile 对你来说将是一个很好的练习。以下是如何进行的一个简要概述。
OpenSSL 被编译为两个相互协作的静态库:libssl
和libcrypto
。查看源代码包,并查看文件夹2_OpenSSL/lib/crypto/jni
和2_OpenSSL/ssl/jni
。这两个库都应该被链接到使用带有 SSL 功能的 libcurl 版本的应用程序中。
通常,为此准备的Android.mk
文件可以像下面的列表这样开始:
include $(CLEAR_VARS)
LOCAL_MODULE := libCurl
LOCAL_SRC_FILES := ../../../Libs.Android/libcurl.$(TARGET_ARCH_ABI).a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libCrypto
LOCAL_SRC_FILES := ../../../Libs.Android/libCrypto.$(TARGET_ARCH_ABI).a
include $(PREBUILT_STATIC_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libSSL
LOCAL_SRC_FILES := ../../../Libs.Android/libSSL.$(TARGET_ARCH_ABI).a
include $(PREBUILT_STATIC_LIBRARY)
在这个文件的最后,只需链接所有的库:
LOCAL_STATIC_LIBRARIES += libCurl
LOCAL_STATIC_LIBRARIES += libSSL
LOCAL_STATIC_LIBRARIES += libCrypto
到此为止,你现在可以处理 SSL 连接了。
FreeImage
FreeImage 是一个流行的位图操作库,Unity 游戏引擎是该库的用户之一(freeimage.sourceforge.net/users.html
)。该库是 libpng
、libjpeg
、libtiff
等之上的全功能封装,提供了快速图像加载例程,无需回退到 Java 代码。
FreeImage 包含一套完整的 Makefiles,适用于不同的*台。按照 处理预编译静态库 部分的说明,编译 Android 的库非常直接。Application.mk
文件与 Curl 的同名文件在一行上有所不同:
APP_MODULES := FreeImage
在 Android.mk
文件中,我们将更改 C 编译标志:
GLOBAL_CFLAGS := -O3 -DHAVE_CONFIG_H=1 -DFREEIMAGE_LIB -DDISABLE_PERF_MEASUREMENT
在以下示例中,我们将实现两个简单的例程,以在各种文件格式中从内存块加载和保存图像。
我们从 FreeImage_LoadFromMemory()
例程开始,它接收 Data
数组和其 Size
作为输入参数,并将这个数组解码成一个包含位图像素的 std::vector<char>
。尺寸信息,宽度和高度,存储在 W
和 H
参数中。颜色深度信息被放入 BitsPerPixel
参数中。可选的 DoFlipV
参数指示代码垂直翻转加载的图像,这在与不同图形 API 的图像存储约定(从上到下或从下到上)打交道时可能需要:
bool FreeImage_LoadFromStream( void* Data,unsigned int Size,
std::vector<ubyte>& OutData,int& W,
int& H,int& BitsPerPixel,bool DoFlipV )
{
我们创建内部内存块,它可以被 FreeImage 例程读取。
FIMEMORY* Mem = FI_OpenMemory(( unsigned char* )Data,
static_cast<unsigned int>( Size )
);
在读取位图之前,我们将以以下方式检测其格式(例如,.jpg
、.bmp
、.png
等):
FREE_IMAGE_FORMAT FIF = FI_GetFileTypeFromMemory( Mem, 0 );
然后,解码后的位图被读取到临时的 FIBITMAP
结构中:
FIBITMAP* Bitmap = FI_LoadFromMemory( FIF, Mem, 0 );
FI_CloseMemory( Mem );
FIBITMAP* ConvBitmap;
如果总位数超过 32 位,例如,每个颜色通道占用超过 8 位,我们很可能处理的是浮点数图像,这将需要一些额外的处理:
bool FloatFormat = FI_GetBPP( Bitmap ) > 32;
if ( FloatFormat )
{
本书并未广泛使用浮点数图像,但了解 FreeImage 支持 OpenEXR 格式下的高动态范围图像是有用的。
注意
OpenEXR 格式以支持每个通道 16 位的浮点数值而著称,并可用于游戏中存储不同 HDR 效果的纹理。
ConvBitmap = FI_ConvertToRGBF( Bitmap );
}
else
{
使用透明度信息来转换图像。如果图像不是透明的,则忽略 alpha 通道:
ConvBitmap = FI_IsTransparent( Bitmap ) ? FI_ConvertTo32Bits( Bitmap ) : FI_ConvertTo24Bits( Bitmap );
}
FI_Unload( Bitmap );
Bitmap = ConvBitmap;
如有必要,我们以下列方式对图像进行垂直翻转:
if ( DoFlipV ) FI_FlipVertical( Bitmap );
提取图像尺寸和颜色信息:
W = FI_GetWidth( Bitmap );
H = FI_GetHeight( Bitmap );
BitsPP = FI_GetBPP( Bitmap );
一旦我们知道尺寸,我们可以调整输出缓冲区的大小,如下所示:
OutData.resize( W * H * ( BitsPerPixel / 8 ) );
最后,我们可以将原始未对齐的位图数据提取到我们的 OutData
向量中。每行紧密排列的数据大小为 W*BitsPP/8
字节:
FI_ConvertToRawBits( &OutData[0],Bitmap, W * BitsPP / 8, BitsPP, 0, 1, 2, false );
临时位图对象被删除,函数优雅地返回:
FI_Unload( Bitmap );
return true;
}
位图保存例程可以以类似的方式实现。首先,我们在 FreeImage 库中分配 FIBITMAP 结构来表示我们的图像:
bool FreeImage_SaveToMemory( const std::string& Ext,ubyte* RawBGRImage,int Width,int Height,int BitsPP,std::vector<ubyte>& OutData )
{
FIBITMAP* Bitmap = FI_Allocate(Width, Height, BitsPP, 0, 0, 0);
原始位图数据被复制到 FIBITMAP 结构中:
memcpy( FI_GetBits( Bitmap ), RawBGRImage, Width * Height * BitsPP / 8 );
FreeImage 使用倒置的垂直扫描线顺序,因此在保存之前我们应该垂直翻转图像:
FI_FlipVertical( Bitmap );
然后,我们将使用用户指定的文件扩展名来检测输出图像的格式:
int OutSubFormat;
FREE_IMAGE_FORMAT OutFormat;
FileExtToFreeImageFormats( Ext, OutSubFormat, OutFormat );
为了保存图像,我们将分配一个动态内存块:
FIMEMORY* Mem = FI_OpenMemory( nullptr, 0);
FI_SaveToMemory()
调用根据选定的格式将我们的原始位图编码成压缩表示形式:
if ( !FI_SaveToMemory( OutFormat,Bitmap, Mem, OutSubFormat ) )
{
return false;
}
编码后,我们将直接访问 FreeImage 内存块:
ubyte* Data = NULL;
uint32_t Size = 0;
FI_AcquireMemory( Mem, &Data, &Size );
然后,我们将字节复制到我们的OutData
向量中:
OutData.resize( Size );
memcpy( &OutData[0], Data, Size );
需要进行一些清理。我们删除内存块和 FIBITMAP 结构:
FI_CloseMemory( Mem );
FI_Unload( Bitmap );
return true;
}
辅助的FileExtToFreeImageFormats()
函数将文件扩展名转换为内部的 FreeImage 格式说明符,并提供多个选项。代码很直观。我们将提供的文件扩展名与多个预定义值进行比较,并填充FIF_FORMAT
和SAVE_OPTIONS
结构:
static void FileExtToFreeImageFormats( std::string Ext,int& OutSubFormat, FREE_IMAGE_FORMAT& OutFormat )
{
OutSubFormat = TIFF_LZW;
OutFormat = FIF_TIFF; std::for_each( Ext.begin(), Ext.end(),[]( char& in )
{
in = ::toupper( in );
}
);
if ( Ext == ".PNG" )
{
OutFormat = FIF_PNG;
OutSubFormat = PNG_DEFAULT;
}
else if ( Ext == ".BMP" )
{
OutFormat = FIF_BMP;
OutSubFormat = BMP_DEFAULT;
}
else if ( Ext == ".JPG" )
{
OutFormat = FIF_JPEG;
OutSubFormat = JPEG_QUALITYSUPERB | JPEG_BASELINE |JPEG_PROGRESSIVE | JPEG_OPTIMIZE;
}
else if ( Ext == ".EXR" )
{
OutFormat = FIF_EXR;
OutSubFormat = EXR_FLOAT;
}
}
这可以根据您的需要进行扩展和自定义。
加载和保存图像
为了使前面的代码可用,我们添加了两个更多例程,它们从磁盘文件中保存和加载图像。第一个,FreeImage_LoadBitmapFromFile()
,加载位图:
bool FreeImage_LoadBitmapFromFile( const std::string& FileName, std::vector<ubyte>& OutData, int& W, int& H, int& BitsPP )
{
std::ifstream InFile( FileName.c_str(),
std::ios::in | std::ifstream::binary );
std::vector<char> Data(
( std::istreambuf_iterator<char>( InFile ) ), std::istreambuf_iterator<char>() );
return FreeImage_LoadFromStream(
( ubyte* )&Data[0], ( int )data.size(),
OutData, W, H, BitsPP, true );
}
我们使用一个简单的函数来提取文件扩展名,它作为文件类型标签:
std::string ExtractExtension( const std::string& FileName )
{
size_t pos = FileName.find_last_of( '.' );
return ( pos == std::string::npos ) ?
FileName : FileName.substr( pos );
}
FreeImage_SaveBitmapToFile()
函数使用标准的std::ofstream
流保存文件:
bool FreeImage_SaveBitmapToFile( const std::string& FileName, ubyte* ImageData, int W, int H, int BitsPP )
{
std::string Ext = ExtractExtension( FileName );
std::vector<ubyte> OutData;
if ( !FreeImage_SaveToMemory( Ext, ImageData, W, H, BitsPP, OutData ) )
{
return false;
}
std::ofstream OutFile( FileName.c_str(),
std::ios::out | std::ofstream::binary );
std::copy( OutData.begin(), OutData.end(), std::ostreambuf_iterator<char>( OutFile ) );
return true;
}
这段代码足以涵盖图像加载库的所有基本使用情况。
FreeType
FreeType 库是一个事实上的标准,用于使用 TrueType 字体渲染高质量文本。由于在几乎任何图形程序中输出文本都是不可避免的,我们给出一个如何使用从等宽 TrueType 文件生成的固定大小字体来渲染文本字符串的例子。
我们将固定大小字体存储在16x16
网格中。此演示应用程序的源字体名为Receptional Receipt
,从1001freefonts.com
下载。以下图像显示了结果16x16
网格的四行:
单个字符占用一个矩形区域,我们将这个区域称为槽。字符矩形的坐标是使用字符的 ASCII 码计算的。网格中的每个槽占用SlotW x SlotH
像素,字符本身居中,大小为CharW x CharH
像素。为了演示,我们简单假设SlotW
是CharW
大小的两倍:
我们限制自己使用最简单的可能使用场景:8 位 ASCII 字符,固定大小的字符字形。为了渲染字符串,我们将遍历其字符并调用尚未编写的RenderChar()
函数:
void RenderStr( const std::string& Str, int x, int y )
{
for ( auto c: Str )
{
RenderChar( c, x, y );
x += CharW;
}
}
字符渲染例程是一个简单的双循环,将字形像素复制到输出图像中:
void RenderChar( char c, int x, int y )
{
int u = ( c % 16 ) * SlotW;
int v = ( c / 16 ) * SlotH;
for ( int y1 = 0 ; y1 < CharH ; y1++ )
for ( int x1 = 0 ; x1 <= CharW ; x1++ )
PutPixel( g_OutBitmap, W, H,
x + x1, y + y1,
GetPixel( Font, FontW, FontH,
x1 + u + CharW, y1 + v)
);
}
PutPixel()
和GetPixel()
例程分别设置和获取位图中的像素。每个像素都是 24 位 RGB 格式:
int GetPixel( const std::vector<unsigned char>& Bitmap, int W, int H, int x, int y )
{
if ( y >= H || x >= W || y < 0 || x < 0 ) { return 0; }
在这里,假设扫描线的宽度等于图像宽度,RGB 三元组的颜色分量数量为 3:
int Ofs = ( y * W + x ) * 3;
使用位运算移位来构建结果的 RGB 值:
return (Bitmap[Ofs+0] << 16) +
(Bitmap[Ofs+1] << 8) +
(Bitmap[Ofs+2]);
}
void PutPixel( std::vector<unsigned char>& Bitmap,int W, int H, int x, int y, int Color )
{
if ( y < 0 || x < 0 || y > H - 1 || x > W - 1 ) { return; }
int Ofs = ( y * W + x ) * 3;
位运算移位和掩码完成了提取工作:
buffer[Ofs + 0] = ( Color ) & 0xFF;
buffer[Ofs + 1] = ( Color >> 8 ) & 0xFF;
buffer[Ofs + 2] = ( Color >> 16 ) & 0xFF;
}
另外还有一个辅助函数Greyscale()
,它使用位运算移位为给定的强度计算 RGB 灰度颜色:
inline int Greyscale( unsigned char c )
{
return ( (255-c) << 16 ) + ( (255-c) << 8 ) + (255-c);
}
对于前面的代码,我们并不需要 FreeType。我们真正只需要该库来生成字体。我们将加载字体数据文件,为其前 256 个字符渲染字形,然后使用生成的字体位图来渲染文本字符串。代码的第一部分生成字体。我们将使用几个变量来存储字体的尺寸:
/// Horizontal size of the character
const int CharW = 32;
const int CharH = 64;
/// Horizontal size of the character slot
const int SlotW = CharW * 2;
const int SlotH = CharH;
const int FontW = 16 * SlotW;
const int FontH = 16 * SlotH;
std::vector<unsigned char> g_FontBitmap;
我们将字体存储在一个标准向量中,可以传递给TestFontRendering()
例程:
void TestFontRendering( const std::vector<char>& Data )
{
LoadFreeImage();
LoadFreeType();
FT_Library Library;
FT_Init_FreeTypePTR( &Library );
FT_Face Face;
FT_New_Memory_FacePTR( Library,
(const FT_Byte*)Data.data(),
(int)Data.size(), 0, &face );
将字符大小固定在 100 dpi:
FT_Set_Char_SizePTR( Face, CharW * 64, 0, 100, 0 );
g_FontBitmap.resize( FontW * FontH * 3 );
std::fill( std::begin(g_FontBitmap), std::end(g_FontBitmap), 0xFF );
我们将在循环中逐个渲染 256 个 ASCII 字符:
for ( int n = 0; n < 256; n++ )
{
将字形图像加载到槽中:
if ( FT_Load_CharPTR( Face, n , FT_LOAD_RENDER ) )
continue;
FT_GlyphSlot Slot = Face->glyph;
FT_Bitmap Bitmap = Slot->bitmap;
计算每个字符的矩形左上角的坐标:
int x = (n % 16) * SlotW + CharW + Slot->bitmap_left;
int y = (n / 16) * SlotH - Slot->bitmap_top + 3*CharH/4;
字符的笔形被复制到g_FontBitmap
位图中:
for ( int i = 0 ; i < ( int )Bitmap.width; i++ )
for ( int j = 0 ; j < ( int )Bitmap.rows; j++ )
PutPixel( g_FontBitmap, FontW, FontH,i + x, j + y,
Greyscale( Bitmap.buffer[j * Bitmap.width + i])
);
}
我们将生成的Font
位图保存到文件中:
FreeImage_SaveBitmapToFile( "test_font.png",
g_FontBitmap.data(), FontW, FontH, 24 );
在字体位图生成结束时,我们将清除与 FreeType 库相关的所有内容:
FT_Done_FacePTR ( Face );
FT_Done_FreeTypePTR( Library );
为了使用我们的等宽字体,我们将声明字符串,计算其在屏幕像素中的宽度,并分配输出位图:
std::string Str = "Test string";
W = Str.length() * CharW;
H = CharH;
g_OutBitmap.resize( W * H * 3 );
std::fill( std::begin(g_OutBitmap), std::end(g_OutBitmap), 0xFF );
TestFontRendering()
例程的末尾只是调用了RenderStr()
:
RenderStr( Str, 0, 0 );
然后将生成的图像保存到文件中:
FreeImage_SaveBitmapToFile( "test_str.png",
g_OutBitmap.data(), W, H, 24 );
}
结果应该看起来像以下图像:
通常在位图字体渲染方面,你不想自己编写位图生成的代码。建议您使用第三方工具来完成这项工作。这样一款免费工具是 AngelCode,可以在www.angelcode.com/products/bmfont
找到。它可以以最优的方式将字形打包到位图中,并生成处理生成的位图所需的数据。
Theora(注:此处 Theora 为一种视频压缩格式的名称,不翻译)
Theora 是来自 Xiph.Org 基金会的一个免费且开源的视频压缩格式。与我们的所有多媒体技术一样,它可以用来在线和光盘上分发电影和视频,而无需像许多其他视频格式那样支付许可和版税费用,或受到任何其他供应商的锁定。它可以在www.theora.org
获取。
为了避免混淆,我们将介绍一些术语。我们所说的比特流是指一些字节的序列。逻辑比特流是对视频或音频数据的某种表示。编解码器,或编码器-解码器,是一组将逻辑比特流编码和解码成一组名为打包比特流的紧凑表示的函数。由于通常的多媒体数据包含多个逻辑比特流,紧凑表示必须被分割成小块,这些小块被称为包。每个包都有一个特定的尺寸、时间戳和与之相关的校验和,以保证包的完整性。比特流和包的方案在以下图像中显示:
逻辑包和打包比特流的包相互混合,形成一个线性序列,保持每个独立比特流的包的顺序。这称为复用。Ogg 库读取.ogg
文件并将其分割成打包比特流。每个比特流都可以使用 Theora、Vorbis 或其他解码器进行解码。
注意
在我们之前的书籍中,Android NDK Game Development Cookbook,Packt Publishing (www.packtpub.com/game-development/android-ndk-game-development-cookbook
),我们通过示例教大家如何解码 Ogg Vorbis 音频流。
在本章中,我们只解决了从文件中提取媒体信息的最简单问题。即使这个简单的操作的代码可能看起来又长又复杂。然而,它可以用不到十个步骤来描述:
-
初始化 OGG 流读取器。
-
开始一个包构建循环:从源文件中读取一堆字节。
-
检查是否有足够的数据来生成另一个逻辑包。
-
如果形成了新的包,检查它是否是
BoS
(流开始)包。 -
尝试使用
BoS
包初始化 Theora 或 Vorbis 解码器。 -
如果我们没有足够的音频和视频流来解码,请转到步骤 2。
-
如果我们没有足够的流信息,继续读取次要流包。
-
初始化 Theora 解码器并提取视频帧信息。
注意
Ogg 流还有一个复杂性级别,因为包被分组形成逻辑页。在前面的伪代码中,我们指的是实际上是页面的包。尽管如此,方案保持不变:读取字节,直到有足够的数据让解码器生成另一个视频帧,或者在我们的情况下,读取视频信息。
我们使用标准的 C++ I/O 流并实现了三个简单的函数:Stream_Read()
、Stream_Seek()
和Stream_Size()
。在后面的第四章,组织虚拟文件系统中,我们将使用自己的 I/O 抽象层重新实现这些方法。让我们打开文件流:
std::ifstream Input( "test.ogv", std::ios::binary );
这是一个从输入流中读取指定字节数的函数:
int Stream_Read( char* OutBuffer, int Size )
{
Input.read( OutBuffer, Size );
return Input.gcount();
}
使用以下代码寻找指定位置:
int Stream_Seek( int Offset )
{
Input.seekg( Offset );
return (int)Input.tellg();
}
要确定文件大小,请使用以下代码:
int Stream_Size()
{
Input.seekg (0, input.end);
int Length = Input.tellg();
Input.seekg( 0, Input.beg );
return Length;
}
首先,应该声明一些变量来存储解码过程的状态、同步对象、当前页面以及音频和视频流:
ogg_sync_state OggSyncState;
ogg_page OggPage;
ogg_stream_state VorbisStreamState;
ogg_stream_state TheoraStreamState;
Theora 解码器状态:
th_info TheoraInfo;
th_comment TheoraComment;
th_setup_info* TheoraSetup;
th_dec_ctx* TheoraDecoder;
Vorbis 解码器状态:
vorbis_info VorbisInfo;
vorbis_dsp_state VorbisDSPState;
vorbis_comment VorbisComment;
vorbis_block VorbisBlock;
函数Theora_Load()
读取文件头并从中提取视频帧信息:
bool Theora_Load()
{
Stream_Seek( 0 );
当前的 Ogg 包将被读取到TempOggPacket
结构中:
ogg_packet TempOggPacket;
需要对一些简单但必要的状态变量进行初始化:
memset( &VorbisStreamState, 0, sizeof( ogg_stream_state ) );
memset( &TheoraStreamState, 0, sizeof( ogg_stream_state ) );
memset( &OggSyncState, 0, sizeof( ogg_sync_state ) );
memset( &OggPage, 0, sizeof( ogg_page ) );
memset( &TheoraInfo, 0, sizeof( th_info ) );
memset( &TheoraComment, 0, sizeof( th_comment ) );
memset( &VorbisInfo, 0, sizeof( vorbis_info ) );
memset( &VorbisDSPState, 0, sizeof( vorbis_dsp_state ) );
memset( &VorbisBlock, 0, sizeof( vorbis_block ) );
memset( &VorbisComment, 0, sizeof( vorbis_comment ) );
OGG_sync_init ( &OggSyncState );
TH_comment_init ( &TheoraComment );
TH_info_init ( &TheoraInfo );
VORBIS_info_init( &VorbisInfo );
VORBIS_comment_init( &VorbisComment );
我们开始读取文件,并使用Done
标志在文件结束或我们有足够的数据获取信息时终止:
bool Done = false;
while ( !Done )
{
char* Buffer = OGG_sync_buffer( &OggSyncState, 4096 );
int BytesRead = ( int )Stream_Read( Buffer, 4096 );
OGG_sync_wrote( &OggSyncState, BytesRead );
if ( BytesRead == 0 )
{
break;
}
while (OGG_sync_pageout( &OggSyncState, &OggPage ) > 0)
{
当我们最终遇到一个完整的包时,我们将检查它是否是BOS
标记,并将数据输出到其中一个解码器:
ogg_stream_state OggStateTest;
if ( !OGG_page_bos( &OggPage ) )
{
if ( NumTheoraStreams > 0 )
{
OGG_stream_pagein( &TheoraStreamState, &OggPage );
}
if ( NumVorbisStreams > 0 )
{
OGG_stream_pagein( VorbisStreamState, &OggPage );
}
Done = true;
break;
}
OGG_stream_init( &OggStateTest,
OGG_page_serialno( &OggPage ) );
OGG_stream_pagein( &OggStateTest, &OggPage );
OGG_stream_packetout( &OggStateTest, &TempOggPacket );
我们将使用两个变量NumTheoraStreams
和NumVorbisStreams
分别计算视频和音频流的数量。在以下几行中,我们将 Ogg 包提供给两个解码器,并查看解码器是否对此有异议:
if ( NumTheoraStreams == 0 )
{
int Ret = TH_decode_headerin( &TheoraInfo, &TheoraComment, &TheoraSetup, &TempOggPacket );
if ( Ret > 0 )
{
下面是 Theora 头信息:
memcpy( &TheoraStreamState, &OggStateTest, sizeof( OggStateTest ) );
NumTheoraStreams = 1;
continue;
}
}
if ( NumVorbisStreams == 0 )
{
int Ret = VORBIS_synthesis_headerin( &VorbisInfo, &VorbisComment, &TempOggPacket );
if ( Ret >= 0 )
{
这是 Vorbis 头:
memcpy( &VorbisStreamState, &OggStateTest, sizeof( OggStateTest ) );
NumVorbisStreams = 1;
continue;
}
}
因为我们只需要 Theora 流信息,所以忽略其他编解码器并丢弃头信息:
OGG_stream_clear( &OggStateTest );
}
}
之前的代码基本上只是计算了流的数量,现在我们应该已经完成了。如果流的数量仍然不足,我们将继续读取并检查次级流头:
while((( NumTheoraStreams > 0 ) && ( NumTheoraStreams < 3 )) || (( NumVorbisStreams > 0 ) && ( NumVorbisStreams < 3 )))
{
int Success = 0;
我们将读取所有可用的包,并检查它是否是一个新的 Theora 流的开始:
while (( NumTheoraStreams > 0 ) &&
( NumTheoraStreams < 3 ) &&
( Success = OGG_stream_packetout( &TheoraStreamState, &TempOggPacket ) ) )
{
if ( Success < 0 ) return false;
if ( !TH_decode_headerin( &TheoraInfo, &TheoraComment, &TheoraSetup, &TempOggPacket ) ) return false;
++NumTheoraStreams;
}
同样的方法,我们将寻找下一个 Vorbis 流的开始:
while ( NumVorbisStreams < 3 && ( Success = OGG_stream_packetout( &VorbisStreamState, &TempOggPacket ) ) )
{
if ( Success < 0 ) return false;
if ( VORBIS_synthesis_headerin( &VorbisInfo, &VorbisComment, &TempOggPacket ) )
return false;
++NumVorbisStreams;
}
while (!Done)
循环的最后一步是检查具有实际帧数据的包,或者如果下一个包不可用,从流中读取更多字节:
if ( OGG_sync_pageout( &OggSyncState, &OggPage ) > 0 )
{
if ( NumTheoraStreams > 0 )
{
OGG_stream_pagein( &TheoraStreamState, &OggPage );
}
if ( NumVorbisStreams > 0 )
{
OGG_stream_pagein( &VorbisStreamState, &OggPage );
}
}
else
{
char* Buffer = OGG_sync_buffer( &OggSyncState, 4096 );
int BytesRead = (int)Stream_Read( Buffer, 4096 );
OGG_sync_wrote( &OggSyncState, BytesRead );
if ( BytesRead == 0 ) return false;
}
}
到目前为止,我们已经找到了所有的流头,并准备好初始化 Theora 解码器。初始化后,我们获取帧宽和帧高:
TheoraDecoder = TH_decode_alloc( &TheoraInfo, TheoraSetup );
Width = TheoraInfo.frame_width;
Height = TheoraInfo.frame_height;
return true;
}
最后,我们清除编解码器的内部结构以避免内存泄漏:
void Theora_Cleanup()
{
if ( TheoraDecoder )
{
TH_decode_free( TheoraDecoder );
TH_setup_free( TheoraSetup );
VORBIS_dsp_clear( &VorbisDSPState );
VORBIS_block_clear( &VorbisBlock );
OGG_stream_clear( &TheoraStreamState );
TH_comment_clear( &TheoraComment );
TH_info_clear( &TheoraInfo );
OGG_stream_clear( &VorbisStreamState );
VORBIS_comment_clear( &VorbisComment );
VORBIS_info_clear( &VorbisInfo );
OGG_sync_clear( &OggSyncState );
}
}
到此为止,我们已经读取了视频参数。在接下来的章节中,一旦我们有了基本的图形和音频渲染能力,我们将回到音频和视频的解码和播放。
代码更为复杂,但与我们的示例非常相似,它被广泛用于LibTheoraPlayer
库源代码中,该代码可在libtheoraplayer.cateia.com
获取。
在本章的示例中,我们将使用大写的函数名称来区分动态库使用和静态链接。如果您想静态链接ogg
、vorbis
和theora
库,可以通过将每个OGG
函数前缀重命名为ogg
来实现。就是这样,只需将大写字母替换为小写字母。
对于示例 Theora 视频内容,我们将参考官方网站,www.theora.org/content
,您可以在那里下载.ogv
文件。
OpenAL
OpenAL 是一个跨*台的音频 API。它旨在高效地渲染多通道三维定位音频,并在许多桌面*台的众多游戏引擎和应用程序中广泛使用。许多移动*台提供了不同的音频 API,例如,OpenSL ES 是一个强有力的竞争者。但是,当可移植性受到威胁时,我们应该选择一个能够在所有所需*台上运行的 API。OpenAL 在 Windows、Linux、OS X、Android、iOS、BlackBerry 10 以及许多其他*台上都有实现。在所有这些操作系统中,除了 Windows 和 Android,OpenAL 都是一等公民,所有库在系统中都可用。在 Windows 上,有一个来自 Creative 的实现。在 Android 上,我们需要自己构建库。我们将使用 Martins Mozeiko 的移植版本pielot.org/2010/12/14/openal-on-android/
。这个库可以通过对Android.mk
和Application.mk
文件进行少量调整来编译为 Android 版本。以下是Android.mk
文件:
TARGET_PLATFORM := android-19
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_ARM_MODE := arm
LOCAL_MODULE := OpenAL
LOCAL_C_INCLUDES := $(LOCAL_PATH) $(LOCAL_PATH)/../include $(LOCAL_PATH)/../OpenAL32/Include
LOCAL_SRC_FILES := ../OpenAL32/alAuxEffectSlot.c \
../OpenAL32/alBuffer.c \
../OpenAL32/alDatabuffer.c \
../OpenAL32/alEffect.c \
../OpenAL32/alError.c \
../OpenAL32/alExtension.c \
../OpenAL32/alFilter.c \
../OpenAL32/alListener.c \
../OpenAL32/alSource.c \
../OpenAL32/alState.c \
../OpenAL32/alThunk.c \
../Alc/ALc.c \
../Alc/alcConfig.c \
../Alc/alcEcho.c \
../Alc/alcModulator.c \
../Alc/alcReverb.c \
../Alc/alcRing.c \
../Alc/alcThread.c \
../Alc/ALu.c \
../Alc/android.c \
../Alc/bs2b.c \
../Alc/null.c
-D
定义是正确编译所需的:
GLOBAL_CFLAGS := -O3 -DAL_BUILD_LIBRARY -DAL_ALEXT_PROTOTYPES -DHAVE_ANDROID=1
此if
块是一种在您想要为 Android 构建 x86 版本的库时,区分 ARM 和 x86 编译器开关的方法:
ifeq ($(TARGET_ARCH),x86)
LOCAL_CFLAGS := $(GLOBAL_CFLAGS)
else
LOCAL_CFLAGS := -mfpu=vfp -mfloat-abi=hard -mhard-float -fno-short-enums -D_NDK_MATH_NO_SOFTFP=1 $(GLOBAL_CFLAGS)
endif
include $(BUILD_STATIC_LIBRARY)
Application.mk
文件是标准的,如下所示:
APP_OPTIM := release
APP_PLATFORM := android-19
APP_STL := gnustl_static
APP_CPPFLAGS += -frtti
APP_CPPFLAGS += -fexceptions
APP_CPPFLAGS += -DANDROID
APP_MODULES := OpenAL
APP_ABI := armeabi-v7a-hard x86
NDK_TOOLCHAIN_VERSION := clang
为了方便您,我们在6_OpenAL
示例中提供了所有的源代码和配置文件。此外,本书中使用的所有库都已为 Android 预编译,您可以在本书源代码包中的Libs.Android
文件夹中找到它们。
将库链接到您的应用程序
在我们继续讨论更多主题之前,本章还有一件事需要讨论。实际上,我们学习了如何构建库,但还没学习如何将您的 Android 应用程序与它们链接。为此,我们需要修改您的应用程序的Android.mk
文件。让我们看看3_FreeImage_Example
示例及其Application.mk
。它以声明预构建的静态库指向二进制文件的声明开始:
include $(CLEAR_VARS)
LOCAL_MODULE := libFreeImage
LOCAL_SRC_FILES :=../../../Libs.Android/libFreeImage.$(TARGET_ARCH_ABI).a
include $(PREBUILT_STATIC_LIBRARY)
在这里,我们在路径中使用$(TARGET_ARCH_ABI)
变量,以透明地处理armeabi-v7a-hard
和x86
版本的库。您可以轻松地添加更多架构。
一旦声明了库,让我们将应用程序与其链接。看看Application.mk
的底部:
LOCAL_STATIC_LIBRARIES += FreeImage
include $(BUILD_SHARED_LIBRARY)
LOCAL_STATIC_LIBRARIES
变量包含了所有必要的库。为了方便起见,您可以省略前缀lib
。
概括
在本章中,我们学习了如何在 Android 上处理预编译的静态库,同样的方法也适用于 OS X 和 Linux,以及如何在 Windows 上进行动态链接,同时不破坏代码的多*台功能。我们学习了如何构建 libcurl
和 OpenSSL
,这样你就可以从 C++ 代码中访问 SSL 连接。FreeImage 和 FreeType 的几个示例展示了如何加载和保存光栅字体图像。使用 libtheora 的示例相当全面;然而,结果却很谦虚,我们只是从视频文件中读取元信息。OpenAL 将作为我们音频子系统的基础框架。
第三章:网络功能
在本章中,我们将学习如何从本地 C/C++代码处理与网络相关的功能。网络任务是异步的,就时间而言是不可预测的。即使底层连接是通过 TCP 协议建立的,也不能保证交付时间,应用程序在等待数据时完全有可能冻结。在 Android SDK 中,这一点被大量的类和设施所隐藏。而在 Android NDK 中,相反地,你必须自己克服这些困难,没有来自任何特定*台帮助者的协助。为了开发响应迅速且安全的应用程序,必须解决许多问题:我们需要完全控制下载过程,限制下载数据的大小,并优雅地处理发生的错误。不过,我们不会深入探讨 HTTP 和 SSL 协议实现的细节,我们将使用 libcurl 和 OpenSSL 库,专注于与应用程序开发相关的高级任务。然而,我们将会更详细地了解如何以可移植的方式实现基本异步机制。本章的前几个例子仅适用于桌面,其目的是展示如何实现跨*台同步原语。但是,在本章的最后,我们将看到如何将这些部分整合到一个移动应用程序中。
侵入式智能指针
在多线程环境中跟踪所有本地内存分配是一个出了名困难的流程,特别是在涉及在不同线程间传递对象所有权时。在 C++中,可以使用智能指针自动化内存管理。标准的std::shared_ptr
类是个不错的起点。然而,我们想要关注更有趣且轻量级的技术。我们也不会使用 Boost 库,因为我们在编译时间上真的想要保持精简。
注意
最新版本的 Android NDK 已完全支持 C++ 11 标准库。如果你对std::shared_ptr
或 Boost 库中的侵入式指针感到更熟悉,可以自由使用这些库中的智能指针。
如其名所示,侵入式智能指针中,引用计数被嵌入到对象中。实现这一点的最简单方式是通过继承以下基类:
class iIntrusiveCounter
{
private:
std::atomic<long> m_RefCounter;
public:
iIntrusiveCounter( ) : m_RefCounter( 0 ) {}
virtual ~iIntrusiveCounter( ) {}
long GetReferenceCounter( ) const volatile
{ return m_RefCounter; }
它使用标准原子变量来保存计数器的值。在 C++ 11 标准库被广泛采用之前,实现一个可移植的原子计数器需要使用特定*台的原子操作,比如 POSIX 或 Windows。如今,使用 C++ 11 可以编写适用于所有*台的干净代码;无论是 Android、Windows、Linux、OS X、iOS,甚至是黑莓 10,如果你愿意的话。以下是我们可以如何增加计数器的示例:
void IncRefCount( )
{
m_RefCounter.fetch_add( 1, std::memory_order_relaxed );
}
使用 ++
运算符替代 fetch_add()
是完全可行的。然而,编译器要求以这种方式递增原子整数变量需要是顺序一致的,这可能在生成的汇编代码中插入冗余的内存屏障。由于我们不对递增值进行任何决策,这里的内存屏障是不必要的,可以放宽内存排序,只要求变量的原子性。这正是 fetch_add()
使用 std::memory_order_relaxed
标志所做的,在一些非 x86 *台上可以生成更快的代码。递减要更复杂一些。确实,我们需要决定何时移除对象,只有在引用计数递减到零时才这样做。
这是正确执行操作的代码:
void DecRefCount()
{
if ( m_RefCounter.fetch_sub( 1, std::memory_order_release ) == 1 )
{
std::memory_order_release
标志意味着对内存位置的运算需要所有先前的内存写入对所有执行相同位置获取操作的线程可见。进入 if
块后,我们将通过插入适当的内存屏障来执行获取操作:
std::atomic_thread_fence( std::memory_order_acquire );
在这一点之后,我们现在可以允许对象执行自杀操作:
delete this;
}
}
};
delete this
习惯用法在 isocpp.org/wiki/faq/freestore-mgmt#delete-this
有解释。
注意
iIntrusiveCounter
类是我们引用计数机制的核心。代码可能看起来非常简单;然而,这个实现的背后逻辑比看起来要复杂得多。有关所有详细细节,请参考 Herb Sutter 的 C++ and Beyond 2012: Herb Sutter - atomic<> Weapons, 1 of 2 演讲:
channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Herb-Sutter-atomic-Weapons-1-of-2
channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Herb-Sutter-atomic-Weapons-2-of-2
现在,我们可以实现一个轻量级的 RAII 泛型智能指针类,它使用我们刚刚编写的计数器基类:
template <class T> class clPtr
{
public:
/// default constructor
clPtr(): FObject( 0 ) {}
/// copy constructor
clPtr( const clPtr& Ptr ): FObject( Ptr.FObject )
{
LPtr::IncRef( FObject );
}
在这里,复制构造函数没有直接调用 FObject->IncRefCount()
方法。而是调用一个辅助函数 LPtr::IncRef()
,它接受 void*
并将对象作为参数传递给该函数。这样做是为了允许我们的侵入式智能指针与那些已声明但尚未定义的类一起使用:
/// move constructor
clPtr( clPtr&& Ptr :): FObject( Ptr.FObject )
{
Ptr.FObject = nullptr;
}
template <typename U> clPtr( const clPtr<U>& Ptr )): FObject( Ptr.GetInternalPtr() )
{
LPtr::IncRef( FObject );
}
从 T*
的隐式构造函数很有用:
clPtr( T* const Object ): FObject( Object )
{
LPtr::IncRef( FObject );
}
与构造函数类似,析构函数使用辅助函数来递减引用计数:
~clPtr()
{
LPtr::DecRef( FObject );
}
若干个命名辅助函数可用于检查智能指针的状态:
/// check consistency
inline bool IsValid() const
{
return FObject != nullptr;
}
inline bool IsNull() const
{
return FObject == nullptr;
}
与其他方法相比,赋值运算相当慢:
/// assignment of clPtr
clPtr& operator = ( const clPtr& Ptr )
{
T* Temp = FObject;
FObject = Ptr.FObject;
LPtr::IncRef( Ptr.FObject );
LPtr::DecRef( Temp );
return *this;
}
但是不包括 move
赋值运算符:
clPtr& operator = ( clPtr&& Ptr )
{
FObject = Ptr.FObject;
Ptr.FObject = nullptr;
return *this;
}
->
运算符对于每个智能指针类都是必不可少的:
inline T* operator -> () const
{
return FObject;
}
这是一个有点棘手的问题:一个自动类型转换运算符,用于将智能指针转换为私有类 clProtector
的实例:
inline operator clProtector* () const
{
if ( !FObject ) return nullptr;
static clProtector Protector;
return &Protector;
}
这种类型转换用于允许像if ( clPtr )
这样的安全空指针检查。这是安全的,因为您不能对生成的指针执行任何操作。内部的私有类clProtector
没有实现delete()
运算符,因此使用它将产生编译错误:
private:
class clProtector
{
private:
void operator delete( void* ) = delete;
};
注意事项
本书的源代码包没有使用 C++ 11 的= delete
表示法来删除函数,只是让它未实现。这是为了与旧编译器保持兼容性。如果你针对的是最新版本的 GCC/Clang 和 Visual Studio,使用= delete
将是非常好的。
让我们回到我们的clPtr
类。不幸的是,标准dynamic_cast<>
运算符不能以原始方式使用,因此我们需要进行替换:
public:
/// 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();
}
template <typename U> inline bool operator == ( const U* Ptr1 )const
{
return FObject == Ptr1;
}
template <typename U> inline bool operator != ( const clPtr<U>&Ptr1 ) const
{
return FObject != Ptr1.GetInternalPtr();
}
这是一个函数,用于简化智能指针与接受原始指针的 API 之间的连接。到基础T*
类型的转换应该是显式的:
inline T* GetInternalPtr() const
{
return FObject;
}
当处理低级指针问题时,一些辅助函数可能很有用。删除对象,不要释放它:
inline void Drop()
{
FObject = nullptr;
}
清除对象,减少引用计数,类似于将其赋值为nullptr
:
inline void Clear()
{
*this = clPtr<T>();
}
最后但同样重要的是,指针本身:
private:
T* FObject;
};
从此,我们可移植的侵入式智能指针是自包含的,可以用于实际应用中。还有一件事要做,那就是一种语法糖。C++ 11 典型的使用auto
关键字,这样可以在表达式中只写一次类型名称。但是,下面的实例化将不起作用,因为当我们希望p
的类型是clPtr< clSomeObject>
时,推导出的p
的类型将是clSomeObject*
:
auto p = new clSomeObject( a, b, c );
使用标准共享指针时,通过使用std::make_shared()
模板辅助函数来解决此问题,该函数返回正确的类型(并在幕后进行一些有用的计数器存储优化):
auto p = std::make_shared<clSomeObject>( a, b, c );
在这里,p
的推导类型是std::shared_ptr<clSomeObject>
,最终符合我们的预期。我们可以使用 C++ 11 提供的完美转发机制和std::forward()
函数创建一个类似的辅助函数:
template< class T, class... Args > clPtr<T> make_intrusive( Args&&... args )
{
return clPtr<T>( new T( std::forward<Args>( args )... ) );
}
这种用法是 C++11 风格的,很自然:
auto p = make_intrusive<clSomeObject>( a, b, c );
智能指针的完整源代码可以在1_IntrusivePtr
示例中找到。现在,我们可以进一步使用这个类作为我们多线程内存管理的基石。
可移植的多线程原语
在撰写本文时,C++11 标准中期待已久的std::thread
在 MinGW 工具链中尚不可用,并且它不具备调整线程优先级的能力,这对于网络来说很重要。因此,我们实现了一个简单的类iThread
,带有虚拟方法Run()
,以允许在我们的代码中进行可移植的多线程:
class iThread
{
内部LPriority
枚举定义了线程优先级类:
public:
enum LPriority
{
Priority_Idle = 0,
Priority_Lowest = 1,
Priority_Low = 2,
Priority_Normal = 3,
Priority_High = 4,
Priority_Highest = 5,
Priority_TimeCritical = 6
};
构造函数和析构函数的代码很简单:
iThread(): FThreadHandle( 0 ), FPendingExit( false )
{}
virtual ~iThread()
{}
Start()
方法创建一个特定于操作系统的线程句柄并开始执行。在这本书的所有示例中,我们不需要推迟线程执行;我们只需使用默认参数调用_beginthreadex()
和pthread_create()
系统例程。EntryPoint()
方法稍后定义:
void Start()
{
void* ThreadParam = reinterpret_cast<void*>( this );
#ifdef _WIN32
unsigned int ThreadID = 0;
FThreadHandle = ( uintptr_t )_beginthreadex( nullptr, 0, &EntryPoint, ThreadParam, 0, &ThreadID );
#else
pthread_create( &FThreadHandle, nullptr, EntryPoint, ThreadParam );
pthread_detach( FThreadHandle );
#endif
}
系统相关的线程句柄和布尔原子变量(指示此线程是否应停止执行)在类的私有部分中声明:
private:
thread_handle_t FThreadHandle;
std::atomic<bool> FpendingExit;
本地线程 API 仅支持 C 函数,因此我们必须声明一个静态包装方法EntryPoint()
,该方法将void*
参数转换为iThread
并调用类的Run()
方法。线程函数的调用约定和结果类型在 POSIX 和 Windows 上有所不同:
#ifdef _WIN32
#define THREAD_CALL unsigned int __stdcall
#else
#define THREAD_CALL void*
#endif
static THREAD_CALL EntryPoint( void* Ptr );
受保护的部分定义了Run()
和NotifyExit()
虚拟方法,这些方法在子类中被重写。GetHandle()
方法允许子类访问特定*台的线程句柄:
protected:
virtual void Run() = 0;
virtual void NotifyExit() {};
thread_handle_t GetHandle() { return FThreadHandle; }
要停止线程,我们将设置FPendingExit
标志并调用NotifyExit()
方法通知线程所有者。可选的Wait
参数强制该方法等待线程的实际终止:
void Exit( bool Wait )
{
FPendingExit = true;
NotifyExit();
if ( !Wait ) { return; }
我们必须确保Exit()
不要从同一线程的Run()
方法中调用,以避免死锁,因此我们将调用GetCurrentThread()
并将结果与我们的句柄进行比较:
if ( GetCurrentThread() != FThreadHandle )
{
对于 Windows,我们将通过调用WaitForSingleObject()
来模拟join
操作,然后通过CloseHandle()
终止线程:
#ifdef _WIN32
WaitForSingleObject(( HANDLE )FThreadHandle, INFINITE );
CloseHandle( ( HANDLE )FThreadHandle );
#else
pthread_join( FThreadHandle, nullptr );
#endif
}
}
在 Android 上,GetCurrentThread()
方法的实现与典型的 POSIX 版本略有不同。因此,这个方法包含了一个三重的#ifdef
子句:
native_thread_handle_t iThread::GetCurrentThread()
{
#if defined( _WIN32)
return GetCurrentThreadId();
#elif defined( ANDROID )
return gettid();
#else
return pthread_self();
#endif
}
EntryPoint()
方法是将我们面向对象的iThread
包装类与特定*台的 C 风格线程 API 联系在一起的粘合剂:
THREAD_CALL iThread::EntryPoint( void* Ptr )
{
iThread* Thread = reinterpret_cast<iThread*>( Ptr );
if ( Thread )
{
Thread->Run();
}
#ifdef _WIN32
_endthreadex( 0 );
return 0;
#else
pthread_exit( 0 );
return nullptr;
#endif
}
最后一个细节是SetPriority()
方法,该方法用于控制线程的 CPU 时间分配。在 Windows 中,该方法的主要部分是将我们的LPriority
枚举转换为windows.h
头文件中定义的数值:
void iThread::SetPriority( LPriority Priority )
{
#ifdef _WIN32
int P = THREAD_PRIORITY_IDLE;
switch(Priority)
{
case Priority_Lowest:
P = THREAD_PRIORITY_LOWEST; break;
case Priority_Low:
P = THREAD_PRIORITY_BELOW_NORMAL; break;
case Priority_Normal:
P = THREAD_PRIORITY_NORMAL; break;
case Priority_High:
P = THREAD_PRIORITY_ABOVE_NORMAL; break;
case Priority_Highest:
P = THREAD_PRIORITY_HIGHEST; break;
case Priority_TimeCritical:
P = THREAD_PRIORITY_TIME_CRITICAL; break;
}
SetThreadPriority( ( HANDLE )FThreadHandle, P );
#else
对于 POSIX,我们将我们的优先级值重新缩放到操作系统中可用的最小和最大优先级之间的整数:
int SchedPolicy = SCHED_OTHER;
int MaxP = sched_get_priority_max( SchedPolicy );
int MinP = sched_get_priority_min( SchedPolicy );
sched_param SchedParam;
SchedParam.sched_priority = MinP + (MaxP - MinP) / (Priority_TimeCritical - Priority + 1);
pthread_setschedparam( FThreadHandle, SchedPolicy, &SchedParam );
#endif
}
现在,我们可以使用iThread
类来构建更有用的高级线程原语。为了实现类似std::mutex
的跨*台轻量级对象,我们将使用 Marcus Geelnard 的 TinyThread 库,该库可以在tinythreadpp.bitsnbites.eu
下载。但是,如果你不需要与旧编译器兼容,也可以自由使用标准互斥锁。
让我们继续处理任务队列。
任务队列
为了处理逻辑工作单元,我们将声明具有Run()
方法的iTask
类,该方法可以执行耗时的操作。类的声明在视觉上与iThread
有些相似。然而,其实例实现了一些相对简短的操作,并且可以在不同的线程中执行:
class iTask: public iIntrusiveCounter
{
public:
iTask()
: FIsPendingExit( false )
, FTaskID( 0 )
, FPriority( 0 )
{};
纯虚方法Run()
应该在子类中被重写以执行实际工作:
virtual void Run() = 0;
下面的方法可选择性地取消任务,与iThread
类中的方法类似。它们的作用是通知宿主线程应取消此任务:
virtual void Exit()
{
FIsPendingExit = true;
}
virtual bool IsPendingExit() const volatile
{
return FIsPendingExit;
}
GetTaskID()
和SetTaskID()
方法访问任务的内部唯一标识符,用于取消执行:
virtual void SetTaskID( size_t ID )
{ FTaskID = ID; };
virtual size_t GetTaskID() const
{ return FTaskID; };
GetPriority()
和SetPriority()
方法由任务调度程序使用,以确定执行任务的顺序:
virtual void SetPriority( int P )
{
FPriority = P;
};
virtual int GetPriority() const
{
return FPriority;
};
类的私有部分包含一个原子退出标志,任务 ID 值和任务优先级:
private:
std::atomic<bool> FIsPendingExit;
size_t FTaskID;
int FPriority;
};
任务的管理由clWorkerThread
类完成。基本上,它是一组iTask
实例的集合,通过AddTask()
方法进行输入。类的私有部分包含iTask
的std::list
和几个同步基元:
class clWorkerThread: public iThread
{
private:
std::list< clPtr<iTask> > FPendingTasks;
clPtr<iTask> FCurrentTask;
mutable tthread::mutex FTasksMutex;
tthread::condition_variable FCondition;
FCurrentTask
字段在内部用于跟踪正在进行的任务。FTasksMutex
字段是一个互斥锁,用于确保对FPendingTasks
的线程安全访问。FCondition
条件变量用于通知列表中任务的可可用性。
AddTask()
方法将新任务插入列表中,并通知Run
方法任务已可用:
virtual void AddTask( const clPtr<iTask>& Task )
{
tthread::lock_guard<tthread::mutex> Lock( FTasksMutex );
FPendingTasks.push_back( Task );
FCondition.notify_all();
}
为了检查是否有未完成的任务,我们将定义GetQueueSize()
方法。该方法使用std::list.size()
,并在当前有活动任务正在运行时增加返回的值:
virtual size_t GetQueueSize() const
{
tthread::lock_guard<tthread::mutex> Lock( FTasksMutex );
return FPendingTasks.size() + ( FCurrentTask ? 1 : 0 );
}
有一个CancelTask()
方法来取消单个任务,以及一个CancelAll()
方法来一次性取消所有任务:
virtual bool CancelTask( size_t ID )
{
if ( !ID ) { return false; }
tthread::lock_guard<tthread::mutex> Lock( FTasksMutex );
首先,我们检查是否有正在运行的任务,并且其 ID 与我们想要取消的 ID 匹配:
if ( FCurrentTask && FCurrentTask->GetTaskID() == ID )
FCurrentTask->Exit();
然后,我们将遍历任务列表,并请求给定 ID 的任务退出,从待处理任务列表中移除它们。这可以通过使用简单的 lambda 表达式来完成:
FPendingTasks.remove_if(
ID
{
if ( T->GetTaskID() == ID )
{
T->Exit();
return true;
}
return false;
}
);
最后,我们通知所有人列表已更改:
FCondition.notify_all();
return true;
}
CancelAll()
方法要简单得多。迭代任务列表,请求每个项目终止;这之后,清空容器并发送通知:
virtual void CancelAll()
{
tthread::lock_guard<tthread::mutex> Lock( FTasksMutex );
if ( FcurrentTask )
{
FcurrentTask->Exit();
}
for ( auto& Task: FpendingTasks )
{
Task->Exit();
}
FpendingTasks.clear();
Fcondition.notify_all();
}
主要工作在Run()
方法中完成,该方法等待下一个任务到达并执行它:
virtual void Run()
{
外层循环使用iThread::IsPendingExit()
例程检查我们是否需要停止这个工作线程:
while ( !IsPendingExit() )
{
ExtractTask()
方法从列表中提取下一个任务。它会等待条件变量直到任务实际可用:
FCurrentTask = ExtractTask();
如果任务有效且未请求取消,我们可以开始执行任务:
if ( FCurrentTask &&
!FCurrentTask->IsPendingExit())
FCurrentTask->Run();
任务完成工作后,我们将清除状态以确保正确的GetQueueSize()
操作:
FCurrentTask = nullptr;
}
}
ExtractTask()
方法在FPendingTasks
列表中实现了一个线程安全的线性搜索,以选择具有最高优先级的iTask
实例:
clPtr<iTask> ExtractTask()
{
tthread::lock_guard<tthread::mutex> Lock( FTasksMutex );
为了避免进行忙等(spinlock)并耗尽 CPU 周期,将检查条件变量:
while ( FPendingTasks.empty() && !IsPendingExit() )
FCondition.wait( FTasksMutex );
如果列表为空,将返回空智能指针:
if ( FPendingTasks.empty() )
return clPtr<iTask>();
Best
变量存储了要执行的选择任务:
auto Best = FPendingTasks.begin();
遍历FPendingTask
列表,并将优先级值与Best
变量中的值进行比较,我们将选择任务:
for ( auto& Task : FPendingTasks )
{
if ( Task->GetPriority() >
( *Best )->GetPriority() ) *Best = Task;
}
最后,我们将从容器中删除选定的任务并返回结果。需要临时变量以确保我们的智能指针不会将引用计数减为零:
clPtr<iTask> Result = *Best;
FPendingTasks.erase( Best );
Return Result;
}
现在,我们已经有了处理异步任务的类。在我们可以继续实际的异步网络连接——异步回调之前,还有一件至关重要的事情要做。
消息泵和异步回调
在上一节中,我们定义了clWorkerThread
和iTask
类,它们允许我们在 C++代码中在 UI 线程之外执行耗时操作。为了组织一个响应式界面,我们最后需要的能力是在不同线程之间传递事件。为此,我们需要一个可调用的接口,它可以封装传递给方法的参数,以及一个线程安全的机制来传递这样的胶囊。
一个很好的候选胶囊是std::packaged_task
,但它在最新的 MinGW 工具链中不受支持。因此,我们将定义自己的轻量级引用计数抽象类iAsyncCapsule
,它实现了一个单一的方法,Invoke()
:
class iAsyncCapsule: public iIntrusiveCounter
{
public:
virtual void Invoke() = 0;
};
我们将包裹在clPtr
中的iAsyncCapsule
实例的优先级集合称为异步队列。clAsyncQueue
类实现了DemultiplexEvents()
方法,该方法将在处理传入事件的线程中调用。
注意
这被称为反应器模式。其文档可以在en.wikipedia.org/wiki/Reactor_pattern
找到。
解复用包括调用所有通过EnqueueCapsule()
方法从其他线程添加的累积iAsyncCapsule
。这两种方法应该是线程安全的,实际上也是。然而,DemultiplexEvents()
在意义上不是可重入的,也就是说,两个线程不应当对同一对象调用DemultiplexEvents()
。这一限制是性能优化的一部分,我们将在后面看到。我们使用两个iAsyncCapsule
容器,并在每次调用DemultiplexEvents()
时切换它们。这使得EnqueueCapsule()
执行更快,因为我们不需要复制队列内容以确保线程安全。否则,由于在互斥锁锁定时我们不应该调用Invoke()
,所以进行复制是必要的。
类的私有部分包含当前使用的队列索引FCurrentQueue
,两个iAsyncCapsule
容器,指向当前队列的指针以及用于防止同时访问FAsyncQueues
数组的互斥锁:
class clAsyncQueue
{
private:
using CallQueue = std::vector< clPtr<iAsyncCapsule> >;
size_t FCurrentQueue;
std::array<CallQueue, 2> FAsyncQueues;
/// switched for shared non-locked access
CallQueue* FAsyncQueue;
tthread::mutex FDemultiplexerMutex;
构造函数初始化当前队列指针和索引:
public:
clAsyncQueue()
: FDemultiplexerMutex()
, FCurrentQueue( 0 )
, FAsyncQueues()
, FAsyncQueue( &FAsyncQueues[0] )
{}
EnqueueCapsule()
方法与WorkerThread::AddTask()
类似。首先,我们创建一个作用域内的lock_guard
对象,然后调用push_back()
以将iAsyncCapsule
对象入队:
virtual void EnqueueCapsule(
const clPtr<iAsyncCapsule>& Capsule )
{
tthread::lock_guard<tthread::mutex>
Lock( FDemultiplexerMutex );
FAsyncQueue->push_back( Capsule );
}
DemultiplexEvents()
方法保存对当前队列的引用:
virtual void DemultiplexEvents()
{
DemultiplexEvents()
被设计为只在单个线程上运行。此时不需要加锁:
CallQueue& LocalQueue = FAsyncQueues[ FCurrentQueue ];
然后,交换当前队列指针。这是一个原子操作,因此我们使用互斥锁来防止访问FAsyncQueue
指针和索引:
{
tthread::lock_guard<tthread::mutex>
Lock( FDemultiplexerMutex );
FCurrentQueue = ( FCurrentQueue + 1 ) % 2;
FAsyncQueue = &FAsyncQueues[ FCurrentQueue ];
}
最后,当前队列中的每个iAsyncCapsule
都会被调用,并且LocalQueue
会被清空:
for ( auto& i: LocalQueue ) i->Invoke();
LocalQueue.clear();
}
};
典型的使用场景是在一个线程向另一个线程发布回调。这里考虑的一个小示例使用了clResponseThread
类,该类有一个无尽循环作为主线程:
class clResponseThread: public iThread, public clAsyncQueue
{
public:
virtual void Run()
{
for (;;) DemultiplexEvents();
}
};
示例clRequestThread
类每秒产生两次事件:
class clRequestThread: public iThread
{
public:
explicit clRequestThread( clAsyncQueue* Target )
: FTarget(Target)
{}
virtual void Run()
{
int id = 0;
for (;;)
{
FTarget->EnqueueCapsule( make_intrusive<clTestCall>( id++ ) );
OS_Sleep( 500 );
}
}
private:
clAsyncQueue* FTarget;
};
测试调用仅打印带有clTestCall
ID 的消息:
class clTestCall: public iAsyncCapsule
{
private:
int id;
public:
explicit clTestCall( int i ): id(i) {}
virtual void Invoke()
{
std::cout "Test " << id << std::endl;
}
};
在main()
函数中,我们创建两个线程并开始一个无限循环:
clResponseThread Responder;
clRequestThread Requester( &Responder );
Responder.Start();
Requester.Start();
for (;;) {}
在下一节中,我们将使用类似的方法通知主线程下载结果。clResponseThread
类成为 UI 线程,而clRequestThread
是一个WorkerThread
方法,其中每个执行的下载任务一旦下载完成就会触发一个事件。
使用 libcurl 进行异步网络操作
在第二章 本地库 中展示了 libcurl 的简单使用。现在,我们使用之前提到的多线程原语来扩展代码,以允许异步下载。
这里引入的clDownloadTask
类跟踪下载过程,并在过程完成时调用回调函数:
class clDownloadTask: public iTask
{
public:
构造函数接受要下载资源的 URL、唯一的任务标识符、回调函数以及指向 downer 实例的指针:
clDownloadTask( const std::string& URL,
size_t TaskID,
const clPtr<clDownloadCompleteCallback>& CB,
clDownloader* Downloader );
我们将关注Run()
、Progress()
和InvokeCallback()
方法,因为它们构成了此类的主要逻辑:
virtual void Run() override;
private:
void Progress( double TotalToDownload,
double NowDownloaded,
double TotalToUpload,
double NowUploaded );
void InvokeCallback();
};
Run()
方法在下载线程上运行;它初始化并使用 libcurl 实际执行资源的下载:
void clDownloadTask::Run()
{
此硬引用是必需的,以防止任务在外部被销毁(如果任务被取消):
clPtr<clDownloadTask> Guard( this );
CURL* Curl = curl_easy_init_P();
libcurl 的初始化代码在这里。所有可能的参数可以在官方文档中找到,地址为curl.haxx.se/libcurl/c/curl_easy_setopt.html
:
curl_easy_setopt_P( Curl, CURLOPT_URL, FURL.c_str() );
curl_easy_setopt_P( Curl, CURLOPT_FOLLOWLOCATION, 1 );
curl_easy_setopt_P( Curl, CURLOPT_NOPROGRESS, false );
curl_easy_setopt_P( Curl, CURLOPT_FAILONERROR, true );
curl_easy_setopt_P( Curl, CURLOPT_MAXCONNECTS, 10 );
curl_easy_setopt_P( Curl, CURLOPT_MAXFILESIZE, DownloadSizeLimit );
curl_easy_setopt_P( Curl, CURLOPT_WRITEFUNCTION,
&MemoryCallback );
curl_easy_setopt_P( Curl, CURLOPT_WRITEDATA, this );
curl_easy_setopt_P( Curl, CURLOPT_PROGRESSFUNCTION, &ProgressCallback );
curl_easy_setopt_P( Curl, CURLOPT_PROGRESSDATA, this );
以下行设置尝试连接时要等待的秒数。使用零值表示无限期等待:
curl_easy_setopt_P( Curl, CURLOPT_CONNECTTIMEOUT, 30 );
在这里,我们设置允许 libcurl 函数执行的最大秒数:
curl_easy_setopt_P( Curl, CURLOPT_TIMEOUT, 600 );
禁用 OpenSSL 对证书的验证,这将允许访问具有自签名证书的站点。然而,在生产代码中,你可能想要删除此模式,以减少中间人攻击的可能性:
curl_easy_setopt_P( Curl, CURLOPT_SSL_VERIFYPEER, 0 );
curl_easy_setopt_P( Curl, CURLOPT_SSL_VERIFYHOST, 0 );
curl_easy_setopt_P( Curl, CURLOPT_HTTPGET, 1 );
注意
在协商 SSL 连接时,服务器会发送一个证书来标识其身份。Curl 验证证书是否真实——也就是说,你可以信任服务器就是证书所说的那个实体。这种信任基于一系列数字签名,根植于你提供的认证机构(CA)证书。
你可以在以下 URL 找到文档:
curl.haxx.se/libcurl/c/CURLOPT_SSL_VERIFYPEER.html
curl.haxx.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html
执行实际下载:
FCurlCode = curl_easy_perform_P( Curl );
curl_easy_getinfo_P( Curl, CURLINFO_RESPONSE_CODE, &FRespCode );
curl_easy_cleanup_P( Curl );
让下载器处理此任务的结果。我们很快就会跟随这段代码:
if ( FDownloader ) { FDownloader->CompleteTask( this ); }
}
私有的InvokeCallback()
成员函数可以被友类clDownloader
访问:
void clDownloadTask::InvokeCallback()
{
tthread::lock_guard<tthread::mutex> Lock( FExitingMutex );
本质上,这只是对FCallback->Invoke()
的调用,并增加了两个运行时检查。第一个检查任务是是否没有被取消:
if ( !IsPendingExit() )
{
if ( FCurlCode != 0 )
{
FResult = nullptr;
}
第二个检查回调的可用性并准备所有参数:
if ( FCallback )
{
FCallback->FTaskID = GetTaskID();
FCallback->FResult = FResult;
FCallback->FTask = clPtr<clDownloadTask>( this );
FCallback->FCurlCode = FCurlCode;
FCallback->Invoke();
FCallback = nullptr;
}
}
}
需要注意的是,回调的调用是在互斥锁锁定的情况下进行的。这样做是为了确保正确的取消行为。然而,InvokeCallback()
并不是直接从clDownloadTask
中调用的。相反,是通过Run()
方法中的FDownloader->CompleteTask( this )
进行间接调用。让我们看看它里面的内容,以及clDownloader
类的核心部分:
class clDownloader: public iIntrusiveCounter
{
public:
explicit clDownloader( const clPtr<clAsyncQueue>& Queue );
virtual ~clDownloader();
这个方法是我们公共下载 API 最重要的部分:
virtual clPtr<clDownloadTask> DownloadURL(
const std::string& URL, size_t TaskID,
const clPtr<clDownloadCompleteCallback>& CB );
virtual bool CancelLoad( size_t TaskID );
virtual void CancelAll();
virtual size_t GetNumDownloads() const;
下面是处理间接调用的代码:
private:
void CompleteTask( clPtr<clDownloadTask> Task );
friend class clDownloadTask;
这是在其中运行clDownloadTask
的线程:
clPtr<clWorkerThread> FDownloadThread;
外部事件队列通过构造函数参数进行初始化:
clPtr<clAsyncQueue> FEventQueue;
};
然而,DownloadURL()
方法是关键的,其实现却出奇地简单:
clPtr<clDownloadTask> DownloadURL( const std::string& URL,size_t TaskID,const clPtr<clDownloadCompleteCallback>& CB )
{
if ( !TaskID || !CB ) { return clPtr<clDownloadTask>(); }
auto Task = make_intrusive<clDownloadTask>(URL, TaskID, CB, this );
FDownloadThread->AddTask( Task );
return Task;
}
实际上,所有繁重的工作都是在前面提到的方法clDownloadTask::Run()
中完成的。在这里,我们只是将新构建的任务排入工作线程队列中。最有趣的事情发生在CompleteTask()
内部:
void clDownloader::CompleteTask( clPtr<clDownloadTask> Task )
{
if ( !Task->IsPendingExit() )
{
if ( FEventQueue )
{
这里,一个回调包装器被插入到事件队列中:
FEventQueue->EnqueueCapsule(
make_intrusive<clCallbackWrapper>(Task) );
}
}
}
辅助类调用了FTask->InvokeCallback()
方法。记住,该方法是在正确的线程上被调用的,它是由事件队列分派的:
class clCallbackWrapper: public iAsyncCapsule
{
public:
explicit clCallbackWrapper(
const clPtr<clDownloadTask> T ):FTask(T) {}
virtual void Invoke() override
{
FTask->InvokeCallback();
}
private:
clPtr<clDownloadTask> FTask;
};
使用示例可以在源代码包的3_Downloader
文件夹中找到。它就像这段代码一样简单:
int main()
{
Curl_Load();
这个队列将处理下载结果:
auto Events = make_intrusive<clAsyncQueue>();
auto Downloader = make_intrusive<clDownloader>( Events );
clPtr<clDownloadTask> Task = Downloader->DownloadURL(
http://downloads.sourceforge.net/freeimage/FreeImage3160.zip,
1, make_intrusive<clTestCallback>() );
while ( !g_ShouldExit ) { Events->DemultiplexEvents(); }
return 0;
}
clTestCallback
类打印下载进度并将结果保存到文件中,在我们的示例中是一个.zip
文件。
注意
我们使用LUrlParser
库从给定的 URL 中提取文件名,github.com/corporateshark/LUrlParser
。
示例代码可以通过输入 make all
使用 MinGW 编译。同样的代码可以在 Android 上运行,无需更改,使用从第二章,原生库编译的 Curl 库。我们建议您在 Android 上尝试此代码,并直接从 C++代码进行一些下载操作。
原生应用中的 Android 授权
本章的大部分内容已经致力于 C++中的低级网络功能,这对于编写多*台代码至关重要。然而,在本章中省略一些 Android 特定的事项是不公*的。让我们通过授权机制来学习如何将其移入 C++代码。为此,我们将需要大量与 Java 代码交互,因为所有授权功能都是 Java 独有的。
注意
在这里,我们假设您已经熟悉如何在 Java 中进行授权检查。官方 Google 文档可以在这里找到:
developer.android.com/google/play/licensing/setting-up.html
developer.android.com/google/play/licensing/adding-licensing.html
本示例的源代码位于 4_Licensing
文件夹中。首先,让我们定义基本常量,这些值应该与 Android SDK 中的匹配。请查看 License.h
文件:
constexpr int LICENSED = 0x0100;
constexpr int NOT_LICENSED = 0x0231;
constexpr int RETRY = 0x0123;
constexpr int ERROR_INVALID_PACKAGE_NAME = 1;
constexpr int ERROR_NON_MATCHING_UID = 2;
constexpr int ERROR_NOT_MARKET_MANAGED = 3;
constexpr int ERROR_CHECK_IN_PROGRESS = 4;
constexpr int ERROR_INVALID_PUBLIC_KEY = 5;
constexpr int ERROR_MISSING_PERMISSION = 6;
然后,Callbacks.h
声明了从授权检查器调用的回调:
void OnStart();
void OnLicensed( int Reason );
void OnLicenseError( int ErrorCode );
主源文件包含那些回调的实现:
#include <stdlib.h>
#include "Callbacks.h"
#include "License.h"
#include "Log.h"
void OnStart()
{
LOGI( "Hello Android NDK!" );
}
void OnLicensed( int Reason )
{
LOGI( "OnLicensed: %i", Reason );
在这里,只有当我们确实未获得授权时才终止应用程序:
if ( Reason == NOT_LICENSED )
{
exit( 255 );
}
}
void OnLicenseError( int ErrorCode )
{
LOGI( "ApplicationError: %i", ErrorCode );
}
让我们深入到 JNI 和 Java 代码中,看看这些回调是如何被调用的。LicenseChecker.cpp
文件包含了对前面提到的回调的静态 Java 方法的一对一映射:
extern "C"
{
JNIEXPORT void JNICALL Java_com_packtpub_ndkmastering_AppActivity_Allow(
JNIEnv* env, jobject obj, int Reason )
{
OnLicensed( Reason );
}
JNIEXPORT void JNICALL Java_com_packtpub_ndkmastering_AppActivity_DontAllow(
JNIEnv* env, jobject obj, int Reason )
{
OnLicensed( Reason );
}
JNIEXPORT void JNICALL Java_com_packtpub_ndkmastering_AppActivity_ApplicationError(
JNIEnv* env, jobject obj, int ErrorCode )
{
OnLicenseError( ErrorCode );
}
}
我们跟随代码进入 AppActivity.java
文件,该文件声明了 CheckLicense()
:
public void CheckLicense( String BASE64_PUBLIC_KEY,
byte[] SALT )
{
String deviceId = Secure.getString( getContentResolver(), Secure.ANDROID_ID );
构造 LicenseCheckerCallback
对象。Google 授权库在完成后会调用它:
m_LicenseCheckerCallback = new AppLicenseChecker();
使用 Policy
构造 LicenseChecker
:
m_Checker = new LicenseChecker( this,
new ServerManagedPolicy(this,
new AESObfuscator( SALT,
getPackageName(), deviceId) ),
BASE64_PUBLIC_KEY);
m_Checker.checkAccess( m_LicenseCheckerCallback );
}
回调的 Java 部分就在这里,位于类声明的底部:
public static native void Allow( int reason );
public static native void DontAllow( int reason );
public static native void ApplicationError( int errorCode );
AppLicenseChecker
类只是调用这些静态方法,将事件路由到 JNI 代码。多么简单!现在,您可以在 C++代码中以可移植的方式处理(和测试)对授权检查事件的反应。使用以下命令为 Android 构建示例,亲自看看吧:
>ndk-build
>ant debug
运行时日志可以通过 logcat
访问。桌面版本可以通过 make all
命令构建,正如本书中的所有示例一样。
Flurry 分析
让我们再接触一个与 Java 相关的事项及其与原生 C++代码的绑定。Flurry.com 是一个流行的应用内分析服务。通过向 Flurry.com 发送信息,可以完成对应用中最常用功能的确定,之后可以通过他们的网页访问收集到的统计数据。
例如,您的应用程序中有几种不同的游戏模式选项:战役、单级别或在线。用户选择其中一种模式,就会生成并发送一个事件到 Flurry.com。我们希望从 C++代码发送这些事件。
请查看 5_Flurry
文件夹中的示例应用程序。main.cpp
文件包含了一个典型的使用示例:
void OnStart()
{
TrackEvent( "FlurryTestEvent" );
}
TrackEvent()
的定义以及 Android 与桌面实现的区别位于 Callbacks.cpp
文件中:
extern "C"
{
void Android_TrackEvent( const char* EventID );
};
void TrackEvent( const char* EventID )
{
#if defined(ANDROID)
Android_TrackEvent( EventID );
#else
printf( "TrackEvent: %s\n", EventID );
#endif
}
Android 实现需要一些 JNI 代码才能工作。请查看以下 jni/JNI.c
文件:
void Android_TrackEvent( const char* EventID )
{
JAVA_ENTER();
jstring jstr = (*env)->NewStringUTF( env, EventID );
FindJavaStaticMethod( env, &Class, &Method,
"com/packtpub/ndkmastering/AppActivity",
"Callback_TrackEvent", "(Ljava/lang/String;)V" );
(*env)->CallStaticVoidMethod( env, Class, Method, jstr );
JAVA_LEAVE();
}
Callback_TrackEvent()
在主活动中定义如下:
public static void Callback_TrackEvent( String EventID )
{
if ( m_Activity == null ) return;
m_Activity.TrackEvent( EventID );
}
public void TrackEvent( String EventID )
{
FlurryAgent.logEvent( EventID );
}
Flurry 分析 API 的其他部分也可以通过类似的方式从 C++路由到 Java,反之亦然。我们建议您在 Flurry 上注册一个账户,获取应用密钥,并尝试自己运行示例。只需替换 FlurryAgent.init()
和 FlurryAgent.onStartSession()
的应用密钥,即可在 Android 上运行应用程序。构建过程很简单,只需使用 ndk-build
和 ant debug
。
总结
在本章中,我们学习了如何实现精简且可移植的多线程原语,例如引用计数侵入式智能指针、工作线程和消息泵,并使用它们创建简单的可移植 C++网络访问框架。我们还稍微涉及了 Java,以展示如何在本地代码中处理许可和用量分析。在下一章中,我们将从网络方面抽身,学习如何使用虚拟文件系统抽象来处理异构文件系统。
第四章:组织虚拟文件系统
在本章中,我们将实现低级别的抽象,以处理操作系统无关的文件和文件系统访问。我们将展示如何实现可移植且透明地访问.apk
文件内部打包的 Android 资源,而不依赖于任何内置 API。在桌面环境中构建可调试的多*台应用程序时,这种方法是必要的。
挂载点
挂载点的概念几乎在现代每一个文件系统中都可以找到。对于跨*台 C++程序来说,以一种统一的方式来访问异构存储设备中的文件非常方便。例如,在 Android 上,每个只读数据文件可以存储在.apk
包内,开发者被迫使用特定的 Android 资产管理 API。在 OSX 和 iOS 上,访问程序束需要另一个 API,在 Windows 上,应用程序应该将其所有内容存储在其文件夹中,该文件夹的物理路径也取决于应用程序安装的位置。
为了在不同*台之间组织文件访问,我们提出了一个浅层类层次结构,它抽象了文件管理的差异,如下面的图所示:
虚拟文件系统是挂载点的集合。每个挂载点都是一个文件系统文件夹的抽象。这种组织方式允许我们将实际的操作系统特定文件访问例程和文件名映射从应用程序代码中隐藏起来。本章涵盖了文件系统、挂载点和流接口的描述。
我们定义了一个iMountPoint
接口,它可以解析虚拟文件名,并创建文件阅读对象的实例:
class iMountPoint: public iIntrusiveCounter
{
public:
检查在这个挂载点是否存在的虚拟文件:
virtual bool FileExists( const std::string& VirtualName ) const = 0;
将虚拟文件名转换为绝对文件名:
virtual std::string MapName( const std::string& VirtualName ) const = 0;
CreateReader()
成员函数创建一个文件阅读器对象,该对象实现了本章后续介绍的iRawFile
接口。这个方法通常只被clFileSystem
类使用:
virtual clPtr<iRawFile> CreateReader( const std::string& VirtualName ) const = 0;
最后两个成员函数获取和设置此挂载点的内部名称。这个字符串稍后会在clFileSystem
接口中使用,以搜索和识别挂载点:
virtual void SetName( const std::string& N ) { FName = N; }
virtual std::string GetName() const { return FName; }
private:
std::string FName;
};
我们的虚拟文件系统实现为挂载点的线性集合。这里的clFileSystem::CreateReader()
方法创建一个iIStream
对象,该对象封装了对文件数据的访问:
clPtr<iIStream> CreateReader( const std::string& FileName ) const;
Mount()
方法将一个物理(这里物理指的是特定操作系统的路径)路径添加到挂载点列表中。如果PhysicalPath
值表示本地文件系统的一个文件夹,则会创建一个clPhysicalMountPoint
实例。如果PhysicalPath
是一个.zip
或.apk
文件的名称,则会将clArchiveMountPoint
实例添加到挂载点列表中。clPhysicalMountPoint
和ArchiveMountPoint
类的定义可以在代码包中的示例1_ArchiveFileAccess
中找到:
void Mount( const std::string& PhysicalPath );
VirtualNameToPhysical()
将我们的虚拟路径转换为特定操作系统的系统文件路径:
std::string VirtualNameToPhysical(
const std::string& Path ) const;
FileExists()
方法检查每个挂载点,以确定文件是否存在于其中一个挂载点中:
bool FileExists( const std::string& Name ) const;
clFileSystem
类的私有部分负责管理内部挂载点列表。FindMountPoint()
方法搜索包含名为FileName
的文件的挂载点。FindMountPointByName()
方法在内部使用,允许文件名称的别名。AddMountPoint()
检查提供的挂载点是否唯一,如果是,则将其添加到FMountPoints
容器中:
private:
clPtr<iMountPoint> FindMountPointByName( const std::string& ThePath );
void AddMountPoint( const clPtr<iMountPoint>& MP );
clPtr<iMountPoint> FindMountPoint( const std::string& FileName ) const;
最终,挂载点集合存储在std::vector
中:
std::vector< clPtr<iMountPoint> > FMountPoints;
};
当我们想在应用程序代码中访问一个文件时,我们是通过文件系统对象g_FS
来实现的:
auto f = g_FS->CreateReader( "test.txt" );
挂载点与流
在 Android 上,test.txt
文件很可能位于.apk
包中,需要在CreateReader()
调用中完成大量工作。test.txt
的数据被提取出来,并创建了一个clMemFileMapper
实例。让我们深入探究文件操作背后的隐藏管道。
CreateReader()
的代码很简单。首先,我们将路径中的斜杠和反斜杠转换为与底层操作系统匹配的样式。然后找到一个包含名为FileName
的文件的挂载点。最后,创建一个clFileMapper
实例。这个类实现了iIStream
接口。让我们仔细看看这些类:
clPtr<iIStream> clFileSystem::CreateReader(
const std::string& FileName ) const
{
std::string Name = Arch_FixFileName( FileName );
clPtr<iMountPoint> MountPoint = FindMountPoint( Name );
在这里,我们使用空对象模式(en.wikipedia.org/wiki/Null_Object_pattern
)来定义非存在文件的中性行为。clNullRawFile
类表示一个不与任何实际设备关联的空文件:
if ( !MountPoint ) { return make_intrusive<clFileMapper>( make_intrusive<clNullRawFile>() ); }
return make_intrusive<clFileMapper>( MountPoint->CreateReader( Name ) );
}
FindMountPoint()
方法遍历挂载点集合,以找到包含给定名称文件的挂载点:
clPtr<iMountPoint> clFileSystem::FindMountPoint( const std::string& FileName ) const
{
if ( FMountPoints.empty() )
{
return nullptr;
}
if ( ( *FMountPoints.begin() )->FileExists( FileName ) )
{
return ( *FMountPoints.begin() );
}
反向迭代挂载点,以便首先检查最*挂载的路径:
for ( auto i = FMountPoints.rbegin();
i != FMountPoints.rend(); ++i )
{
if ( ( *i )->FileExists( FileName ) )
{
return ( *i );
}
}
return *( FMountPoints.begin() );
}
clFileSystem
类将大部分工作委托给各个iMountPoint
实例。例如,检查文件是否存在是通过找到适当的iMountPoint
对象并询问该点是否存在文件来执行的:
bool clFileSystem::FileExists( const std::string& Name ) const
{
if ( Name.empty() || Name == "." ) { return false; }
clPtr<iMountPoint> MP = FindMountPoint( Name );
return MP ? MPD->FileExists( Name ) : false;
}
也可以通过适当的iMountPoint
实例找到物理文件名:
std::string clFileSystem::VirtualNameToPhysical(
const std::string& Path ) const
{
if ( FS_IsFullPath( Path ) ) { return Path; }
clPtr<iMountPoint> MP = FindMountPoint( Path );
return ( !MP ) ? Path : MP->MapName( Path );
}
物理文件名不直接用于访问文件。例如,如果挂载了一个存档,并且我们想要访问存档中的文件,那么该文件的物理路径对操作系统来说是没有意义的。相反,一切都由挂载点抽象化,物理文件名只在我们应用程序中作为标识符使用。
只有当新的挂载点是唯一的时候,它才会被添加到集合中;没有理由允许重复。
void clFileSystem::AddMountPoint( const clPtr<iMountPoint>& MP )
{
if ( !MP ) { return; }
if ( std::find( FMountPoints.begin(), FMountPoints.end(), MP ) == FMountPoints.end() )
{
FMountPoints.push_back( MP );
}
}
clFileSystem::Mount()
的代码选择要实例化的挂载点类型:
void clFileSystem::Mount( const std::string& PhysicalPath )
{
clPtr<iMountPoint> MP;
我们在这里使用了一个简单的硬编码逻辑。如果路径以.zip
或.apk
子字符串结尾,我们将实例化clArchiveMountPoint
:
if ( Str::EndsWith( PhysicalPath, ".apk" ) || Str::EndsWith( PhysicalPath, ".zip" ) )
{
auto Reader = make_intrusive<clArchiveReader>();
bool Result = Reader->OpenArchive( CreateReader( PhysicalPath ) );
MP = make_intrusive<clArchiveMountPoint>( Reader );
}
else
否则,我们将检查clPhysicalPath
是否存在,然后创建clPhysicalMountPoint
:
{
#if !defined( OS_ANDROID )
if ( !FS_FileExistsPhys( PhysicalPath ) )
return;
#endif
MP = make_intrusive<clPhysicalMountPoint>(PhysicalPath );
}
如果创建挂载点成功,我们设置其名称并将其添加到集合中:
MP->SetName( PhysicalPath );
AddMountPoint( MP );
}
我们稍后会回到挂载点的实现。现在,我们转向流。对文件的实际读取访问是通过iIStream
接口完成的:
class iIStream: public iIntrusiveCounter
{
public:
接下来的两个方法分别获取虚拟和物理文件名:
virtual std::string GetVirtualFileName() const = 0;
virtual std::string GetFileName() const = 0;
Seek()
方法设置绝对读取位置;GetSize()
和GetPos()
确定大小和当前的读取位置,而Eof()
检查是否已达到文件末尾:
virtual void Seek( const uint64 Position ) = 0;
virtual uint64 GetSize() const = 0;
virtual uint64 GetPos() const = 0;
virtual bool Eof() const = 0;
Read()
方法将指定Size
的数据块读取到无类型内存缓冲区Buf
中:
virtual uint64 Read( void* Buf, const uint64 Size ) = 0;
最后两个方法使用内存映射实现对文件数据的数组式访问。第一个返回与此文件对应的共享内存的指针:
virtual const ubyte* MapStream() const = 0;
第二个方法返回从当前文件位置开始的内存指针。这对于在块和内存映射访问样式之间无缝切换非常方便:
virtual const ubyte* MapStreamFromCurrentPos() const = 0;
};
为了避免 UI 线程阻塞,这些方法通常应该在工作者线程上调用。
所有访问物理文件的工作都在clFileMapper
类中完成。它是iIStream
接口的一个实现,将所有 I/O 操作委托给实现iRawFile
接口的对象。iRawFile
本身在应用程序代码中不直接使用,所以让我们先看看clFileMapper
类:
class clFileMapper: public iIStream
{
public:
构造函数只是存储了对iRawFile
实例的引用,并重置了读取指针:
explicit FileMapper( clPtr<iRawFile> File ):
FFile( File ), FPosition( 0 ) {}
virtual ~FileMapper() {}
GetVirtualFileName()
和GetFileName()
方法使用iRawFile
的实例分别获取虚拟和物理文件名:
virtual std::string GetVirtualFileName() const
{ return FFile->GetVirtualFileName(); }
virtual std::string GetFileName() const
{ return FFile->GetFileName(); }
Read()
方法模拟了std::ifstream.read
和libc
中的read()
例程。它可能看起来不寻常,但读取是通过访问内存映射文件的memcpy
调用完成的。iRawFile::GetFileData()
的描述将澄清这些问题:
virtual uint64 Read( void* Buf, uint64 Size )
{
uint64 RealSize = ( Size > GetBytesLeft() ) ? GetBytesLeft() : Size;
if ( !RealSize ) { return 0; }
memcpy( Buf, ( FFile->GetFileData() + FPosition ),static_cast<size_t>( RealSize ) );
FPosition += RealSize;
return RealSize;
}
定位和内存映射都委托给底层的iRawFile
实例:
virtual void Seek( const uint64 Position)
{ FPosition = Position; }
virtual uint64 GetSize() const
{ return FFile->GetFileSize(); }
virtual bool Eof() const
{ return ( FPosition >= FFile->GetFileSize() ); }
virtual const ubyte* MapStream() const
{ return FFile->GetFileData(); }
virtual const ubyte* MapStreamFromCurrentPos() const
{ return ( FFile->GetFileData() + FPosition ); }
私有部分包含了对iRawFile
的引用和当前的读取位置:
private:
clPtr<iRawFile> FFile;
uint64 FPosition;
};
现在我们可以声明iRawFile
接口,它非常简单:
class iRawFile: public iIntrusiveCounter
{
public:
iRawFile() {}
virtual ~iRawFile() {}
前四个方法获取和设置虚拟和物理文件名:
std::string GetVirtualFileName() const
{ return FVirtualFileName; }
std::string GetFileName() const
{ return FFileName; }
void SetVirtualFileName( const std::string& VFName )
{ FVirtualFileName = VFName; }
void SetFileName( const std::string& FName )
{ FFileName = FName; }
这个接口的实质在于以下两个方法,它们获取文件数据的原始指针和文件的大小:
virtual const ubyte* GetFileData() const = 0;
virtual uint64 GetFileSize() const = 0;
私有部分包含文件名的字符串:
private:
std::string FFileName;
std::string FVirtualFileName;
};
声明完所有接口后,我们可以继续进行它们的实现。
访问宿主文件系统中的文件
我们从clRawFile
类开始,它使用特定于操作系统的内存映射例程将文件映射到内存中:
class clRawFile: public iRawFile
{
public:
RawFile() {}
virtual ~RawFile() { Close(); }
Open()
成员函数完成了大部分繁重的工作。它存储物理和虚拟文件名,打开文件句柄并创建文件的映射视图:
bool Open( const std::string& FileName,
const std::string& VirtualFileName )
{
SetFileName( FileName );
SetVirtualFileName( VirtualFileName );
FSize = 0;
FFileData = nullptr;
在 Windows 上,我们使用CreateFileA()
来打开文件。像往常一样,我们将特定于操作系统的部分用#ifdef
块括起来。:
#ifdef _WIN32
FMapFile = CreateFileA( FFileName.c_str(), GENERIC_READ,
FILE_SHARE_READ, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_RANDOM_ACCESS,
nullptr );
打开文件后,我们创建一个映射对象,并使用 MapViewOfFile()
系统调用获取指向文件数据的指针:
FMapHandle = CreateFileMapping( FMapFile,
nullptr, PAGE_READONLY, 0, 0, nullptr );
FFileData = ( ubyte* )MapViewOfFile( FMapHandle, FILE_MAP_READ, 0, 0, 0 );
如果出现错误,请关闭句柄并取消操作:
if ( !FFileData )
{
CloseHandle( ( HANDLE )FMapHandle );
return false;
}
为了防止读取超出文件末尾,我们应该获取文件的大小。在 Windows 中是这样完成的:
DWORD dwSizeLow = 0, dwSizeHigh = 0;
dwSizeLow = ::GetFileSize( FMapFile, &dwSizeHigh );
FSize = ( ( uint64 )dwSizeHigh << 32 )
| ( uint64 )dwSizeLow;
在 Android 中,我们使用 open()
初始化文件句柄,并使用 fstat()
获取其大小:
#else
FFileHandle = open( FileName.c_str(), O_RDONLY );
struct stat FileInfo;
如果 fstat()
成功,我们可以获取其大小。如果文件大小非零,我们调用 mmap()
函数将文件映射到内存中:
if ( !fstat( FFileHandle, &FileInfo ) )
{
FSize = static_cast<uint64_t>( FileInfo.st_size );
确保对于大小为零的文件不调用 mmap()
:
if ( FSize )
FFileData = ( uint8_t* )( mmap( nullptr, FSize, PROT_READ, MAP_PRIVATE, FFileHandle, 0 ) );
}
一旦我们有了 mmap
-ed 的内存块,就可以立即关闭文件句柄。这是标准做法:
close( FFileHandle );
#endif
return true;
}
Close()
方法取消内存块映射并关闭文件句柄:
void Close()
{
在 Windows 中,我们使用 UnmapViewOfFile()
和 CloseHandle()
系统调用:
#ifdef _WIN32
if ( FFileData ) { UnmapViewOfFile( FFileData ); }
if ( FMapHandle ) { CloseHandle( (HANDLE)FMapHandle ); }
CloseHandle( ( HANDLE )FMapFile );
在 Android 中,我们调用 munmap()
函数:
#else
if ( FFileData )
{
munmap( reinterpret_cast<void*>( FFileData ), FSize );
}
#endif
}
clRawFile
类的其余部分包含两个简单的方法,返回文件数据指针和文件大小。私有部分声明文件句柄、文件大小和数据指针:
virtual const ubyte* GetFileData() const { return FFileData; }
virtual uint64 GetFileSize() const { return FSize; }
private:
#ifdef _WIN32
HANDLE FMapFile;
HANDLE FMapHandle;
#else
int FFileHandle;
#endif
ubyte* FFileData;
uint64 FSize;
};
要使用 clFileSystem
类访问虚拟文件系统中的物理文件夹,我们声明了 clPhysicalMountPoint
类,代表宿主文件系统上的单个文件夹:
class clPhysicalMountPoint: public iMountPoint
{
public:
clPhysicalMountPoint
的构造函数通过添加一个路径分隔符(根据底层操作系统的约定是斜杠或反斜杠)来修复物理文件夹路径:
clPhysicalMountPoint( const std::string& PhysicalName ):FPhysicalName( PhysicalName )
{
Str_AddTrailingChar( &FPhysicalName, PATH_SEPARATOR );
}
virtual ~PhysicalMountPoint() {}
FileExists()
方法使用依赖于操作系统的例程来检查文件是否存在:
virtual bool FileExists( const std::string& VirtualName ) const override
{
return FS_FileExistsPhys( MapName( VirtualName ) );
}
MapName()
方法通过添加 FPhysicalName
前缀将虚拟文件转换为物理文件名。FS_IsFullPath()
例程在以下代码中定义:
virtual std::string MapName( const std::string& VirtualName )const override
{
return FS_IsFullPath( VirtualName ) ? VirtualName : ( FPhysicalName + VirtualName );
}
clRawFile
实例是在 clPhysicalMountPoint::CreateReader()
方法中创建的:
virtual clPtr<iRawFile> CreateReader(
const std::string& VirtualName ) const override
{
std::string PhysName = MapName( VirtualName );
auto File = make_intrusive<clRawFile>();
if ( File->Open( FS_ValidatePath( PhysName ), VirtualName ) ) { return File; }
return make_intrusive<clNullRawFile>();
}
类的私有部分包含文件夹的物理名称:
private:
std::string FPhysicalName;
};
为了完成此代码,我们必须实现一些服务例程。第一个是 FS_IsFullPath()
,它检查路径是否为绝对路径。对于 Android,这意味着路径以 /
字符开始,对于 Windows,完整路径必须以 <drive>:\
子字符串开始,其中 <drive>
是驱动器字母:
inline bool FS_IsFullPath( const std::string& Path )
{
return ( Path.find( ":\\" ) != std::string::npos ||
#if !defined( _WIN32 )
( Path.length() && Path[0] == '/' ) ||
#endif
Path.find( ":/" ) != std::string::npos ||
Path.find( ".\\" ) != std::string::npos );
}
FS_ValidatePath()
方法将每个斜杠或反斜杠字符替换为特定于*台的 PATH_SEPARATOR
:
inline std::string FS_ValidatePath( const std::string& PathName )
{
std::string Result = PathName;
for ( size_t i = 0; i != Result.length(); ++i )
if ( Result[i] == '/' || Result[i] == '\\' )
{
Result[i] = PATH_SEPARATOR;
}
return Result;
}
要检查文件是否存在,我们使用 stat()
例程,其语法在 Windows 和 Android 上略有不同:
inline bool FS_FileExistsPhys( const std::string& PhysicalName )
{
#ifdef _WIN32
struct _stat buf;
int Result = _stat( FS_ValidatePath( PhysicalName ).c_str(),
&buf );
#else
struct stat buf;
int Result = stat( FS_ValidatePath( PhysicalName ).c_str(),
&buf );
#endif
return Result == 0;
}
PATH_SEPARATOR
是一个特定于*台的字符常量:
#if defined( _WIN32 )
const char PATH_SEPARATOR = '\\';
#else
const char PATH_SEPARATOR = '/';
#endif
上述代码足以访问直接存储在宿主文件系统上的文件。接下来,我们继续了解其他抽象概念以获取 Android .apk
包。
内存文件
以下 iRawFile
接口的实现封装了对未类型化内存块的访问作为文件访问。我们将使用此类来访问存档中的未压缩数据。
class clMemRawFile: public iRawFile
{
public:
参数化构造函数用于初始化指向数据缓冲区的指针及其大小:
clMemRawFile( const uint8_t* BufPtr, size_t BufSize, bool OwnsBuffer )
: FOwnsBuffer( OwnsBuffer )
, FBuffer( BufPtr )
, FBufferSize( BufSize )
{}
对于一个内存块来说,内存映射是微不足道的,我们只需返回存储的原始指针:
virtual const uint8_t* GetFileData() const override
{ return FBuffer; }
virtual uint64_t GetFileSize() const override
{ return FBufferSize; }
private:
const uint8_t* FBuffer;
size_t FBufferSize;
};
当我们处理归档文件读取时,将回到这个类。现在,让我们熟悉一个更多重要的概念,这是透明访问.apk
包所必需的。
别名
前一节提到的文件抽象非常强大。它们可以用来创建嵌套的挂载点,以访问其他文件中打包的文件。让我们通过定义clAliasMountPoint
来展示这种方法的灵活性,它类似于 Unix 或 NTFS 文件系统中的符号链接。
该实现将每个iMountPoint::
方法调用重定向到另一个挂载点实例,同时在运行时通过为我们想要访问的每个虚拟文件名添加一个特定的FAlias
前缀来转换文件名:
class clAliasMountPoint: public iMountPoint
{
public:
explicit clAliasMountPoint( const clPtr<iMountPoint>& Src )
: Falias(), FMP( Src )
{}
virtual bool FileExists( const std::string& VirtualName ) const { return FMP->FileExists( FAlias + VirtualName ); }
virtual std::string MapName( const std::string& VirtualName ) const { return FMP->MapName( FAlias + VirtualName ); }
virtual clPtr<iRawFile> CreateReader( const std::string& VirtualName ) const { return FMP->CreateReader( FAlias + VirtualName ); }
private:
std::string FAlias;
clPtr<iMountPoint> FMP;
};
我们添加了FileSystem::AddAlias()
成员函数,它通过将它们与FAlias
前缀连接起来,来装饰现有挂载点的文件名:
void clFileSystem::AddAlias( const std::string& SrcPath, const std::string& Alias )
{
if (clPtr<iMountPoint> MP = FindMountPointByName( SrcPath ) ) AddMountPoint(new AliasMountPoint( MP, Alias ) );
}
这种机制可以用来将路径(如assets/
)透明地重映射到我们文件系统的根目录,这对于 Android 上的应用程序功能至关重要。
写文件
在开始更复杂的归档解包工作之前,让我们先休息一下,看看如何写入文件。我们使用iOStream
接口,它只声明了四个纯虚方法。GetFileName()
方法返回虚拟文件名。Seek()
方法设置写入位置,GetFilePos()
返回它。Write()
方法接受一个无类型的内存缓冲区并将其写入输出流:
class iOStream: public iIntrusiveCounter
{
public:
iOStream() {};
virtual ~iOStream() {};
virtual std::string GetFileName() const = 0;
virtual void Seek( const uint64 Position ) = 0;
virtual uint64 GetFilePos() const = 0;
virtual uint64 Write(const void* Buf, const uint64 Size) = 0;
};
我们在这里提供的iOStream
的唯一实现是clMemFileWriter
,它将一个无类型的内存块视为输出流。这个类用于访问.zip
文件中的数据。首先,数据被解包,然后使用clMemRawFile
进行包装:
class clMemFileWriter: public iOStream
{
public:
实际的底层内存块由存储在此类中的clBlob
对象通过 RAII 管理(en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization
):
clMemFileWriter()
: FBlob( make_intrusive<clBlob>() )
, FFileName()
, FPosition( 0 )
{}
explicit clMemFileWriter( const clPtr<clBlob>& Blob )
: FBlob( Blob )
, FFileName()
, FPosition( 0 )
{}
Seek()
方法增加当前的写入位置:
virtual void Seek( const uint64 Position )
{
FPosition = ( Position > FBlob->GetSize() ) ? FBlob->GetSize() - 1 : Position;
}
Write()
方法重定向到clBlob
对象:
virtual uint64_t Write( const void* Buf, uint64_t Size ) override
{
return FBlob->AppendBytes( Buf,static_cast<size_t>( Size ) );
}
伴随的源代码包含了clFileWriter
类的实现,其中包含了类似于clRawFile::Open()
的Open()
方法。Write()
方法使用系统 I/O 例程将数据写入物理文件。
现在,我们有足够的脚手架代码可以进一步处理.zip
归档。
访问归档文件
由于.apk
实际上就是一个花哨的.zip
压缩包,我们使用了 Jean-loup Gailly 的 ZLib 库结合 MiniZIP 库来从中获取压缩文件。完整的源代码大约有 500 千字节大小,因此我们提供了两个文件,libcompress.c
和libcompress.h
,它们可以轻松地集成到任何构建过程中。我们的目标是实现clArchiveMountPoint
,它枚举归档中的文件,为特定文件解压缩数据,并创建一个clMemFileMapper
来读取其数据。为此,我们需要引入一个辅助类,clArchiveReader
,它读取和解压缩.zip
归档文件:
class clArchiveReader: public iIntrusiveCounter
{
private:
clArchiveReader
类中定义的私有的sFileInfo
结构体封装了一组有用的文件属性以及指向压缩文件数据的指针:
struct sFileInfo
{
/// offset to the file
uint64 FOffset;
/// uncompressed file size
uint64 FSize;
/// compressed file size
uint64 FCompressedSize;
/// Compressed data
void* FSourceData;
};
clArchiveReader
类的私有部分包含一个sFileInfo
结构的集合,在FFileInfos
字段中,一个包含大写文件名的FFileNames
向量,一个包含归档内文件名的FReadFileNames
向量,以及一个std::map
对象,它将每个文件名映射到解压文件向量FExtractedFromArchive
中的索引:
std::vector<sFileInfo> FFileInfos;
std::vector<std::string> FFileNames;
std::vector<std::string> FRealFileNames;
mutable std::map<std::string, int> FFileInfoIdx;
std::map<int, const void*> FExtractedFromArchive;
FSourceFile
字段保存指向.apk
文件的源文件流的指针:
clPtr<iIStream> FSourceFile;
public:
clArchiveReader()
: FFileInfos()
, FRealFileNames()
, FFileInfoIdx()
, FSourceFile()
{}
virtual ~clArchiveReader()
{ CloseArchive(); }
OpenArchive()
成员函数调用Enumerate_ZIP()
来填充FFileInfos
容器。CloseArchive()
执行一些必要的清理工作:
bool OpenArchive( const clPtr<iIStream>& Source )
{
if ( !Source ) { return false; }
if ( !CloseArchive() ) { return false; }
if ( !Source->GetSize() ) { return false ; }
FSourceFile = Source;
return Enumerate_ZIP();
}
bool CloseArchive()
{
FFileInfos.clear();
FFileInfoIdx.clear();
FFileNames.clear();
FRealFileNames.clear();
ClearExtracted();
FSourceFile = nullptr;
return true;
}
下面将详细描述长的ExtractSingleFile()
方法。它接受来自归档的压缩文件名和一个包含文件数据的iOStream
对象。AbortFlag
是指向原子布尔标志的指针,用于多线程解压缩。解压缩器会不时地轮询它。如果值设置为true
,则内部解压缩循环会提前终止,ExtractSingleFile()
返回false
。
Progress
指针用于更新解压缩进程的进度,这也应该是原子操作。如果归档文件已加密,可以提供一个可选的Password
参数:
bool ExtractSingleFile( const std::string& FileName,
const std::string& Password, std::atomic<int>* AbortFlag,
std::atomic<float>* Progress, const clPtr<iOStream>& Out );
接下来的两个方法使用FFileInfos
向量来检查此归档中是否存在文件并获取其解压缩的大小:
bool FileExists( const std::string& FileName ) const
{
return GetFileIdx( FileName ) > -1;
}
uint64 GetFileSizeIdx( const std::string& FileName ) const
{
return ( Idx > -1 ) ? FFileInfos[ Idx ].FSize : 0;
}
GetFileDataIdx()
方法首先检查文件是否已经解压缩。在这种情况下,返回来自FExtractedFromArchive
的指针:
const void* GetFileDataIdx( int Idx )
{
if ( Idx <= -1 ) { return nullptr; }
if ( FExtractedFromArchive.count( Idx ) > 0 )
{
return FExtractedFromArchive[Idx]->GetDataConst();
}
如果文件尚未解压缩,将调用GetFileData_ZIP()
函数,并从clBlob
返回一个已解包的内存块:
auto Blob = GetFileData_ZIP( Idx );
if ( Blob )
{
FExtractedFromArchive[Idx] = Blob;
return Blob->GetDataConst();
}
return nullptr;
}
GetFileIdx()
方法将FileName
映射到FFileInfos
向量内部的索引。它使用辅助的FFileInfoIdx
对象来存储字符串到索引的对应关系:
int GetFileIdx( const std::string& FileName ) const
{
return ( FFileInfoIdx.count( FileName ) > 0 ) ? FFileInfoIdx[ FileName ] : -1;
}
最后两个公共函数返回归档中的文件数量和每个文件的名称:
size_t GetNumFiles() const { return FFileInfos.size(); }
std::string GetFileName( int Idx ) const
{ return FFileNames[Idx]; }
clArchiveReader
类的私有部分声明了用于解压缩数据管理的内部方法。Enumerate_ZIP()
方法通过读取归档头填充FFileInfos
容器。GetFileData_ZIP()
成员函数从归档中提取文件数据:
private:
bool Enumerate_ZIP();
const void* GetFileData_ZIP( size_t Idx );
ClearExtracted()
方法是从CloseArchive()
中调用的。它会释放每个解压文件所分配的内存。这里的一切都是通过clBlob
类使用 RAII 管理的:
void ClearExtracted()
{
FExtractedFromArchive.clear();
}
让我们看看使用ExtractSingleFile()
方法的GetFileData_ZIP()
方法的实现:
clPtr<clBlob> clArchiveReader::GetFileData_ZIP( int Idx )
{
if ( FExtractedFromArchive.count( Idx ) > 0 )
{
return FExtractedFromArchive[ Idx ];
}
创建包含解压缩数据的clMemFileWriter
对象:
clPtr<clMemFileWriter> Out =
clFileSystem::CreateMemWriter( "mem_blob",
FFileInfos[ Idx ].FSize );
ExtractSingleFile()
处理解压缩。在这里我们使用了一个阻塞调用(AbortFlag
参数为nullptr
)和一个空密码:
if ( ExtractSingleFile( FRealFileNames[ Idx ], "",
nullptr, nullptr, Out ) )
{
如果调用成功,我们从clMemFileWriter
对象返回解压缩的内容:
return Out->GetBlob();
}
return make_intrusive<clBlob>();
}
ExtractSingleFile()
方法创建zlib
读取对象,将读取器定位在压缩文件数据的开头,并调用ExtractCurrentFile_ZIP()
方法以执行实际解压缩:
bool clArchiveReader::ExtractSingleFile(
const std::string& FileName, const std::string& Password,
std::atomic<int>* AbortFlag, std::atomic<float>* Progress,
const clPtr<iOStream>& Out )
{
std::string ZipName = FileName;
std::replace( ZipName.begin(), ZipName.end(), '\\', '/' );
clPtr<iIStream> TheSource = FSourceFile;
FSourceFile->Seek( 0 );
我们创建内部结构,允许zlib
从我们的iIStream
对象中读取。稍后在Enumerate_ZIP()
中也会进行同样的操作。fill_functions()
例程以及与此相关的所有回调都在本节下面描述:
zlib_filefunc64_def ffunc;
fill_functions( TheSource.GetInternalPtr(), &ffunc );
unzFile UnzipFile = unzOpen2_64( "", &ffunc );
if ( unzLocateFile(UnzipFile, ZipName.c_str(), 0) != UNZ_OK )
{
如果在归档中没有找到文件,则返回false
:
return false;
}
一旦定位了读取器,我们调用ExtractCurrentFile_ZIP()
方法:
int ErrorCode = ExtractCurrentFile_ZIP( UnzipFile,
Password.empty() ? nullptr : Password.c_str(),
AbortFlag, Progress, Out );
unzClose( UnzipFile );
return ErrorCode == UNZ_OK;
}
我们解压缩器的核心在于ExtractCurrentFile_Zip
()。该方法接收一个内存块作为输入,读取文件的解压缩字节,并将其写入输出流:
int ExtractCurrentFile_ZIP( unzFile UnzipFile,
const char* Password, std::atomic<int>* AbortFlag,
std::atomic<float>* Progress, const clPtr<iOStream>& Out )
{
char FilenameInzip[1024];
unz_file_info64 FileInfo;
unzGetCurrentFileInfo64()
函数检索未压缩的文件大小。我们用它来计算总进度并将其写入Progress
参数:
int ErrorCode = unzGetCurrentFileInfo64( UnzipFile,
&FileInfo, FilenameInzip, sizeof( FilenameInzip ),
nullptr, 0, nullptr, 0 );
if ( ErrorCode != UNZ_OK ) { return ErrorCode; }
unzOpenCurrentFilePassword()
调用初始化了解压缩过程:
ErrorCode = unzOpenCurrentFilePassword( uf, password );
if ( ErrorCode != UNZ_OK ) { return err; }
方法的最后部分是一个循环,该循环读取一包解压缩的字节,并调用Out
对象的iOStream::Write
方法:
uint64_t FileSize = ( uint64_t )FileInfo.uncompressed_size;
在基于内存映射文件的示例实现中,我们将 64 位文件大小转换为size_t
。这实际上在 32 位目标上打破了大于 2Gb 文件的支持。然而,这种权衡在大多数实际移动应用中是可以接受的,除非你正在编写通用的.zip
解压缩器,当然:
Out->Reserve( ( size_t )FileSize );
unsigned char Buffer[ WRITEBUFFERSIZE ];
uint64_t TotalBytes = 0;
int BytesRead = 0;
do
{
如果需要,我们可以通过检查AbortFlag
指针(由另一个线程设置)来决定是否跳出循环:
if ( AbortFlag && *AbortFlag ) break;
unzReadCurrentFile()
函数执行到输出流的解压缩:
BytesRead = unzReadCurrentFile( UnzipFile, Buffer, WRITEBUFFERSIZE );
if ( BytesRead < 0 ) { break; }
if ( BytesRead > 0 )
{
TotalBytes += BytesRead;
Out->Write( Buffer, BytesRead );
}
写入解压缩数据后,我们相应地更新Progress
计数器:
if ( Progress )
{
*Progress = (float)TotalBytes / (float)FileSize;
}
}
while ( BytesRead > 0 );
最后,我们关闭UnzipFile
读取器对象:
ErrorCode = unzCloseCurrentFile( UnzipFile );
return ErrorCode;
}
归档中文件的枚举是通过另一个名为Enumerate_ZIP()
的成员函数完成的:
bool Enumerate_ZIP()
{
clPtr<iIStream> TheSource = FSourceFile;
FSourceFile->Seek( 0 );
首先,我们填充zlib
所需的回调以读取自定义文件流,在本例中是我们的iIStream
对象:
zlib_filefunc64_def ffunc;
fill_functions( TheSource.GetInternalPtr(), &ffunc );
unzFile UnzipFile = unzOpen2_64( "", &ffunc );
然后,读取归档的头部以确定压缩文件的数量:
unz_global_info64 gi;
int ErrorCode = unzGetGlobalInfo64( uf, &gi );
对于每个压缩文件,我们提取稍后用于解压缩的信息:
for ( uLong i = 0; i < gi.number_entry; i++ )
{
if ( ErrorCode != UNZ_OK ) { break; }
char filename_inzip[256];
unz_file_info64 file_info;
ErrorCode = unzGetCurrentFileInfo64( UnzipFile, &file_info, filename_inzip, sizeof(filename_inzip), nullptr, 0, nullptr, 0 );
if ( ErrorCode != UNZ_OK ) { break; }
if ( ( i + 1 ) < gi.number_entry )
{
ErrorCode = unzGoToNextFile( UnzipFile );
if ( ErrorCode != UNZ_OK ) { break; }
}
在每次迭代中,我们填充sFileInfo
结构并将其存储在FFileInfos
向量中:
sFileInfo Info;
Info.FOffset = 0;
Info.FCompressedSize = file_info.compressed_size;
Info.FSize = file_info.uncompressed_size;
FFileInfos.push_back( Info );
文件名中的所有反斜杠都被转换为在归档内路径元素之间起分隔作用的字符。FFileInfoIdx
映射被填充,以便快速查找文件索引:
std::string TheName = Arch_FixFileName(filename_inzip);
FFileInfoIdx[ TheName ] = ( int )FFileNames.size();
FFileNames.emplace_back( TheName );
FRealFileNames.emplace_back( filename_inzip );
}
最后,我们清理zlib
读取器对象并返回成功代码:
unzClose( UnzipFile );
return true;
}
让我们仔细看看fill_functions()
方法。内存块包含在iIStream
中,因此我们实现了一组zlib
需要的回调,以便与我们的流类一起工作。第一个方法zip_fopen()
对iIStream
进行准备:
static voidpf ZCALLBACK zip_fopen ( voidpf opaque, const void* filename, int mode )
{
( ( iIStream* )opaque )->Seek( 0 );
return opaque;
}
从iIStream
读取字节的操作在zip_fread()
中实现:
static uLong ZCALLBACK zip_fread ( voidpf opaque, voidpf stream, void* buf, uLong size )
{
iIStream* S = ( iIStream* )stream;
int64 CanRead = ( int64 )size;
int64 Sz = S->GetSize();
int64 Ps = S->GetPos();
if ( CanRead + Ps >= Sz ) { CanRead = Sz - Ps; }
if ( CanRead > 0 ) { S->Read( buf, ( uint64 )CanRead ); }
else { CanRead = 0; }
return ( uLong )CanRead;
}
zip_ftell()
函数告诉iIStream
中的当前位置:
static ZPOS64_T ZCALLBACK zip_ftell(voidpf opaque, voidpf stream)
{
return ( ZPOS64_T )( ( iIStream* )stream )->GetPos();
}
zip_fseek()
例程设置读取指针,就像libc
的fseek()
一样:
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->GetSize();
switch ( origin )
{
case ZLIB_FILEFUNC_SEEK_CUR:
NewPos += ( int64 )S->GetPos(); 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;
}
对于iIstream
类,fclose()
和ferror()
的类似操作是微不足道的:
static int ZCALLBACK zip_fclose( voidpf opaque, voidpf stream )
{
return 0;
}
static int ZCALLBACK zip_ferror( voidpf opaque, voidpf stream )
{
return 0;
}
辅助fill_functions()
例程填充了zlib
使用的回调结构:
void fill_functions( iIStream* Stream, zlib_filefunc64_def* pzlib_filefunc_def )
{
pzlib_filefunc_def->zopen64_file = zip_fopen;
pzlib_filefunc_def->zread_file = zip_fread;
pzlib_filefunc_def->zwrite_file = NULL;
pzlib_filefunc_def->ztell64_file = zip_ftell;
pzlib_filefunc_def->zseek64_file = zip_fseek;
pzlib_filefunc_def->zclose_file = zip_fclose;
pzlib_filefunc_def->zerror_file = zip_ferror;
pzlib_filefunc_def->opaque = Stream;
}
这就是关于低级解压缩细节的全部内容。让我们进入更友好的抽象和包装领域。clArchiveMountPoint
类包装了clArchiveReader
的一个实例,并实现了CreateReader()
、FileExists()
和MapName()
方法:
class clArchiveMountPoint: public iMountPoint
{
public:
explicit clArchiveMountPoint( const clPtr<ArchiveReader>& R )
: FReader(R) {}
CreateReader()
方法实例化clMemRawFile
类并附加一个提取的内存块:
virtual clPtr<iRawFile> CreateReader(
const std::string& VirtualName ) const
{
std::string Name = Arch_FixFileName( VirtualName );
const void* DataPtr = FReader->GetFileData( Name );
size_t FileSize = static_cast<size_t>( FReader->GetFileSize( Name ) );
auto File = clMemRawFile::CreateFromManagedBuffer( DataPtr, FileSize );
File->SetFileName( VirtualName );
File->SetVirtualFileName( VirtualName );
return File;
}
FileExists()
方法是对clArchiveReader::FileExists()
的间接调用:
virtual bool FileExists( const std::string& VirtualName )const
{
return FReader->FileExists( Arch_FixFileName( VirtualName ) );
}
对于此类挂载点,MapName()
的实现是微不足道的:
virtual std::string MapName( const std::string& VirtualName ) const
{ return VirtualName; }
私有部分只包含对clArchiveReader
对象的引用:
private:
clPtr<clArchiveReader> FReader;
};
显而易见,简单的clArchiveMountPoint
的缺点在于其非异步阻塞实现。构造函数接受一个完全初始化的clArchiveReader
对象,这意味着我们需要阻塞直到clArchiveReader::OpenArchive()
完成其工作。克服此问题的一种方法是在不同的线程上运行OpenArchive()
,在任务队列中,并在解析归档后创建挂载点。当然,所有后续调用CreateReader()
以期望从此挂载点获取数据的操作应该推迟,直到收到信号。我们鼓励读者使用前一章讨论的clWorkerThread
类实现这种异步机制。更复杂的归档挂载点实现可以接受构建的clArchiveReader
并自行调用OpenArchive()
。这需要更复杂的架构,因为clFileSystem
和/或clArchiveMountPoint
类应该能够访问专用的工人线程。然而,它本质上将所有耗时的解压缩操作复杂性隐藏在简洁的接口背后。
访问应用程序资产
要在 Android 上的 C++代码中访问.apk
包内的数据,我们需要使用 Java 代码获取.apk
的路径,并使用 JNI 将结果传递给我们的 C++代码。
在onCreate()
方法中,将来自getApplication().getApplicationInfo().sourceDir
的值传递给我们的本地代码:
@Override protected void onCreate( Bundle icicle )
{
onCreateNative( getApplication().getApplicationInfo().sourceDir );
}
public static native void onCreateNative( String APKName );
onCreateNative()
的实现可以在1_ArchiveFileAccess\jni\Wrappers.cpp
中找到,如下所示:
extern "C"
{
JNIEXPORT void JNICALL
Java_com_packtpub_ndkmastering_AppActivity_onCreateNative( JNIEnv* env, jobject obj, jstring APKName )
{
g_APKName = ConvertJString( env, APKName );
LOGI( "APKName = %s", g_APKName.c_str() );
OnStart( g_APKName );
}
}
我们使用ConvertJString()
函数将jstring
转换为std::string
。JNI 方法GetStringUTFChars()
和ReleaseStringUTFChars()
获取和释放指向字符串的 UTF8 编码字符数组的指针:
std::string ConvertJString( JNIEnv* env, jstring str )
{
if ( !str ) { return 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;
}
在main.cpp
文件中的OnStart()
回调中实现了简单的使用示例。它挂载路径,在 Android 上创建归档挂载点,打开归档test.zip
并列出其内容。在桌面上,此代码运行并读取存储在assets/test.zip
的test.zip
:
void OnStart( const std::string& RootPath )
{
auto FS = make_intrusive<clFileSystem>();
FS->Mount( "" );
FS->Mount( RootPath );
FS->AddAliasMountPoint( RootPath, "assets" );
const char* ArchiveName = "test.zip";
auto File = FS->CreateReader( ArchiveName );
auto Reader = make_intrusive<clArchiveReader>();
if ( !Reader->OpenArchive( File ) )
{
LOGI( "Bad archive: %s", ArchiveName );
return;
}
遍历此归档中的所有文件并打印它们的名字和内容:
for ( size_t i = 0; i != Reader->GetNumFiles(); i++ )
{
LOGI( "File[%i]: %s", i,
Reader->GetFileName( i ).c_str() );
const char* Data = reinterpret_cast<const char*>( Reader->GetFileDataIdx( i ) );
LOGI( "Data: %s", std::string( Data,
static_cast<size_t>(
Reader->GetFileSizeIdx( i ) ) ).c_str() );
}
}
查看并尝试1_ArchiveFileAccess
示例。它为在桌面上调试 Android 文件访问代码提供了很好的体验。使用make all
构建桌面环境,使用ndk-build & ant debug
构建 Android。
概述
在本章中,我们学习了如何以与*台无关的方式通过 C++处理文件和.apk
归档。我们将在后续章节中使用此功能来访问文件。
第五章:跨*台音频流
在本章中,我们考虑构建交互式移动应用程序所需的最后一个非视觉组件。我们寻找的是一个真正可移植的音频播放实现,适用于 Android 和桌面 PC。我们建议使用 OpenAL 库,因为它在桌面*台上已经非常成熟。音频播放本质上是一个异步过程,因此解码并将数据提交给声音 API 应该在单独的线程上完成。我们将基于第三章的网络编程中的多线程代码创建一个音频流库。
原始未压缩音频可能占用大量内存,因此经常使用不同种类的压缩格式。我们将在本章考虑其中一些格式,并展示如何使用原生 C++代码和流行的第三方库在 Android 中播放它们。
初始化和播放
本章节我们将使用跨*台的 OpenAL 音频库。为了使所有示例保持简洁且自包含,我们从可以播放未压缩.wav
文件的最小化示例开始。
让我们简要描述一下产生声音需要做些什么。OpenAL 的例程处理播放和录音过程中遇到的对象。ALCdevice
对象代表音频硬件的一个单元。由于多个线程可能同时产生声音,因此引入了另一个名为ALCcontext
的对象。首先,应用程序打开一个设备,然后创建一个上下文并将其附加到打开的设备上。每个上下文都维护着多个Audio Source
对象,因为即使单个应用程序也可能需要同时播放多个声音。
我们越来越接*实际的声音产生。还需要一个对象作为波形容器,这称为缓冲区。音频录音可能相当长,所以我们不会将整个声音作为一个缓冲区提交。我们以小块读取样本,并使用几个缓冲区(通常是一对)将这些块提交到音频源的队列中。
以下伪代码描述了如何播放完全适合内存的声音:
-
首先打开一个设备,创建一个上下文,并将上下文附加到设备上。
-
创建一个音频源,分配一个声音缓冲区。
-
将波形数据加载到缓冲区中。
-
将缓冲区入队到音频源。
-
等待播放完成。
-
销毁缓冲区、源和上下文,并关闭设备。
在第 5 步有一个明显的问题。我们无法将应用程序的 UI 线程阻塞几秒钟,因此声音播放必须是异步的。幸运的是,OpenAL 调用是线程安全的,我们可以在没有自己进行任何 OpenAL 同步的情况下在单独的线程中执行播放。
让我们检查示例1_InitOpenAL
。为了在步骤 3 中执行波形加载并尽可能保持代码简单,我们取一个.wav
文件并将其加载到clBlob
对象中。在步骤 2 中,我们创建一个音频源和缓冲区,其参数与WAV
头中的参数相对应。步骤 1、4 和 6 仅包含一些 OpenAL API 调用。步骤 5 通过在原子条件变量上进行忙等待循环来完成。
这个示例的本地 C++入口点从创建一个单独的音频线程开始,该线程声明为全局对象g_Sound
。g_FS
对象包含clFileSystem
类的实例,用于从文件加载音频数据:
clSoundThread g_Sound;
clPtr<clFileSystem> g_FS;
int main()
{
g_FS = make_intrusive<clFileSystem>();
g_FS->Mount( "." );
g_Sound.Start();
g_Sound.Exit( true );
return 0;
}
clSoundThread
类包含一个 OpenAL 设备和上下文。音频源和缓冲区句柄也为此单一源单一缓冲区的示例而声明:
class clSoundThread: public iThread
{
ALCdevice* FDevice;
ALCcontext* FContext;
ALuint FSourceID;
ALuint FBufferID;
Run()
方法负责所有初始化、加载和结束工作:
virtual void Run()
{
要使用 OpenAL 例程,我们应该加载库。对于 Android、Linux 和 OS X,实现很简单,我们只需使用静态链接库即可。然而,对于 Windows,我们加载OpenAL32.dll
文件,并从动态链接库中获取所有必要的函数指针:
LoadAL();
首先,我们打开一个设备并创建一个上下文。alcOpenDevice()
的nullptr
参数意味着我们正在使用默认的音频设备:
FDevice = alcOpenDevice( nullptr );
FContext = alcCreateContext( FDevice, nullptr );
alcMakeContextCurrent( FContext );
然后我们创建一个音频源并将其音量设置为最大级别:
alGenSources( 1, &FSourceID );
alSourcef( FSourceID, AL_GAIN, 1.0 );
波形的加载,对应于我们伪代码中的第 3 步,通过将整个.wav
文件读取到clBlob
对象中完成:
auto data = LoadFileAsBlob( g_FS, "test.wav" );
可以通过以下方式访问头文件:
const sWAVHeader* Header = ( const sWAVHeader* )Blob->GetData();
我们从clBlob
中复制字节到声音缓冲区,跳过头文件对应大小的字节数:
const unsigned char* WaveData = ( const unsigned char* )Blob->GetData() +
sizeof( sWAVHeader );
PlayBuffer( WaveData, Header->DataSize,
Header->SampleRate );
现在让我们忙等待声音播放完毕:
while ( IsPlaying() ) {}
最后,我们停止源,删除所有对象并卸载 OpenAL 库:
alSourceStop( FSourceID );
alDeleteSources( 1, &FSourceID );
alDeleteBuffers( 1, &FBufferID );
alcDestroyContext( FContext );
alcCloseDevice( FDevice );
UnloadAL();
}
clSoundThread
类还包含两个辅助方法。IsPlaying()
方法通过请求其状态来检查声音是否仍在播放:
bool IsPlaying() const
{
int State;
alGetSourcei( FSourceID, AL_SOURCE_STATE, &State );
return State == AL_PLAYING;
}
PlayBuffer()
方法创建一个缓冲区对象,用Data
参数中的波形填充它并开始播放:
void PlayBuffer( const unsigned char* Data, int DataSize, int SampleRate )
{
alBufferData( FBufferID, AL_FORMAT_MONO16,
Data, DataSize, SampleRate );
alSourcei( FSourceID, AL_BUFFER, FBufferID );
alSourcei( FSourceID, AL_LOOPING, AL_FALSE );
alSourcef( FSourceID, AL_GAIN, 1.0f );
alSourcePlay( FSourceID );
}
上述代码依赖于两个全局函数。Env_Sleep()
函数以给定的毫秒数休眠。Windows 版本的代码与 Android 和 OS X 略有不同:
void Env_Sleep( int Milliseconds )
{
#if defined(_WIN32)
Sleep( Milliseconds );
#elif defined(ANDROID)
std::this_thread::sleep_for(
std::chrono::milliseconds( Milliseconds ) );
#else
usleep( static_cast<useconds_t>( Milliseconds ) * 1000 );
#endif
}
注意
我们在 Windows 上使用Sleep()
以与一些缺乏对std::chrono
支持的 MinGW 发行版兼容。如果你想要使用 Visual Studio,只需坚持使用std::this_thread::sleep_for()
。
LoadFileAsBlob()
函数使用提供的clFileSystem
对象将文件内容加载到内存块中。我们在后续的大部分代码示例中重复使用这个例程。
clPtr<clBlob> LoadFileAsBlob( const clPtr<clFileSystem>& FileSystem, const std::string& Name )
{
auto Input = FileSystem->CreateReader( Name );
auto Res = make_intrusive<clBlob>();
Res->AppendBytes( Input->MapStream(), Input->GetSize() );
return Res;
}
如果你在桌面机器上通过输入make all
编译并运行此示例,你应该能听到一个短暂的叮当声。在我们结束 Android 应用程序之前,让我们进一步了解如何进行声音流处理。
流式声音
现在我们能够播放短音频样本,是时候将音频系统组织成类,并仔细查看2_Streaming
示例了。长音频样本(如背景音乐)在解压缩形式下需要大量内存。流式传输是一种小块小块地、逐片解压缩它们的技术。clAudioThread
类负责初始化并处理除播放声音之外的所有工作:
class clAudioThread: public iThread
{
public:
clAudioThread()
: FDevice( nullptr )
, FContext( nullptr )
, FInitialized( false )
{}
virtual void Run()
{
if ( !LoadAL() ) { return; }
FDevice = alcOpenDevice( nullptr );
FContext = alcCreateContext( FDevice, nullptr );
alcMakeContextCurrent( FContext );
FInitialized = true;
while ( !IsPendingExit() ) { Env_Sleep( 100 ); }
alcDestroyContext( FContext );
alcCloseDevice( FDevice );
UnloadAL();
}
此方法用于将音频线程的开始与其用户同步:
virtual void WaitForInitialization() const
{
while ( !FInitialized ) {}
}
private:
std::atomic<bool> FInitialized;
ALCdevice* FDevice;
ALCcontext* FContext;
};
clAudioSource
类代表单一声音产生实体。波形数据不是存储在源本身中,我们推迟对clAudioSource
类的描述。现在,我们介绍提供下一个音频缓冲区数据的iWaveDataProvider
接口类。对iWaveDataProvider
实例的引用存储在clAudioSource
类中:
class iWaveDataProvider: public iIntrusiveCounter
{
public:
音频信号属性存储在这三个字段中:
int FChannels;
int FSamplesPerSec;
int FBitsPerSample;
iWaveDataProvider()
: FChannels( 0 )
, FSamplesPerSec( 0 )
, FBitsPerSample( 0 ) {}
两个纯虚方法提供了对音频源当前播放的波形数据的访问。它们应在实际的解码器子类中实现:
virtual unsigned char* GetWaveData() = 0;
virtual size_t GetWaveDataSize() const = 0;
IsStreaming()
方法告诉我们此提供程序是否代表连续流或如前一个示例中的单个音频数据块。StreamWaveData()
方法加载、解码或生成GetWaveData()
函数访问的缓冲区中的值;它通常也在子类中实现。当clAudioSource
需要更多音频数据以排队进入缓冲区时,它会调用StreamWaveData()
方法:
virtual bool IsStreaming() const { return false; }
virtual int StreamWaveData( int Size ) { return 0; }
最后一个辅助函数返回 OpenAL 使用的内部数据格式。这里我们只支持每样本 8 位或 16 位的立体声和单声道信号:
ALuint GetALFormat() const
{
if ( FBitsPerSample == 8 )
return ( FChannels == 2 ) ?
AL_FORMAT_STEREO8 : AL_FORMAT_MONO8;
if ( FBitsPerSample == 16 )
return ( FChannels == 2 ) ?
AL_FORMAT_STEREO16 : AL_FORMAT_MONO16;
return AL_FORMAT_MONO8;
}
};
我们的基本声音解码是在clStreamingWaveDataProvider
类中完成的。它包含FBuffer
数据向量和其中的有用字节数:
class clStreamingWaveDataProvider: public iWaveDataProvider
{
public:
clStreamingWaveDataProvider()
: FBufferUsed( 0 )
{}
virtual bool IsStreaming() const override
{ return true; }
virtual unsigned char* GetWaveData() override
{ return ( unsigned char* )&FBuffer[0]; }
virtual size_t GetWaveDataSize() const override
{ return FBufferUsed; }
std::vector<char> FBuffer;
size_t FBufferUsed;
};
我们准备描述实际执行繁重任务的clAudioSource
类。构造函数创建一个 OpenAL 音频源对象,设置音量级别并禁用循环:
class clAudioSource: public iIntrusiveCounter
{
public:
clAudioSource()
: FWaveDataProvider( nullptr )
, FBuffersCount( 0 )
{
alGenSources( 1, &FSourceID );
alSourcef( FSourceID, AL_GAIN, 1.0 );
alSourcei( FSourceID, AL_LOOPING, AL_FALSE );
}
我们有两种不同的使用场景。如果附加的iWaveDataProvider
支持流式传输,我们需要创建并维护至少两个声音缓冲区。这两个缓冲区都被加入到 OpenAL 播放队列中,并在其中一个缓冲区播放完成后进行交换。在每次交换事件中,我们调用iWaveDataProvider
的StreamWaveData()
方法将数据流式传输到下一个音频缓冲区。如果iWaveDataProvider
不支持流式传输,我们只需要一个在开始时初始化的单个缓冲区。
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();
StreamBuffer( FBufferID[0], BUFFER_SIZE );
StreamBuffer( FBufferID[1], BUFFER_SIZE );
alSourceQueueBuffers( FSourceID, 2, &FBufferID[0] );
}
alSourcePlay( FSourceID );
}
Stop()
和Pause()
方法分别调用适当的 OpenAL 例程来停止和暂停播放:
void Stop()
{
alSourceStop( FSourceID );
}
void Pause()
{
alSourcePause( FSourceID );
UnqueueAll();
}
LoopSound()
和SetVolume()
方法控制播放参数:
void LoopSound( bool Loop )
{
alSourcei( FSourceID, AL_LOOPING, Loop ? 1 : 0 );
}
void SetVolume( float Volume )
{
alSourcef( FSourceID, AL_GAIN, Volume );
}
IsPlaying()
方法是从上一个示例中复制而来的:
bool IsPlaying() const
{
int State;
alGetSourcei( FSourceID, AL_SOURCE_STATE, &State );
return State == AL_PLAYING;
}
StreamBuffer()
方法将新产生的音频数据写入其中一个缓冲区:
int StreamBuffer( unsigned int BufferID, int Size )
{
int ActualSize = FWaveDataProvider->StreamWaveData( Size );
alBufferData( BufferID,
FWaveDataProvider->GetALFormat(),
FWaveDataProvider->GetWaveData(),
( int )FWaveDataProvider->GetWaveDataSize(),
FWaveDataProvider->FSamplesPerSec );
return ActualSize;
}
Update()
方法应该足够频繁地被调用,以防止音频缓冲区出现下溢。然而,只有当附加的 iWaveDataProvider
表示音频流时,此方法才重要:
void Update( float DeltaSeconds )
{
if ( !FWaveDataProvider ) { return; }
if ( !IsPlaying() ) { return; }
if ( FWaveDataProvider->IsStreaming() )
{
我们询问 OpenAL 已经处理了多少个缓冲区:
int Processed;
alGetSourcei( FSourceID, AL_BUFFERS_PROCESSED, &Processed );
我们从队列中移除每个已处理的缓冲区,并调用 StreamBuffer()
来解码更多数据。最后,我们将缓冲区重新加入播放队列:
while ( Processed-- )
{
unsigned int BufID;
alSourceUnqueueBuffers( FSourceID, 1, &BufID );
StreamBuffer( BufID, BUFFER_SIZE );
alSourceQueueBuffers( FSourceID, 1, &BufID );
}
}
}
析构函数会停止播放并销毁 OpenAL 音频源和缓冲区:
virtual ~clAudioSource()
{
Stop();
alDeleteSources( 1, &FSourceID );
alDeleteBuffers( FBuffersCount, &FBufferID[0] );
}
BindWaveform()
方法将一个新的 iWaveDataProvider
附加到这个音频源实例:
void BindWaveform( clPtr<iWaveDataProvider> Wave )
{
FWaveDataProvider = Wave;
if ( !Wave ) { return; }
对于流式的 iWaveDataProvider
,我们需要两个缓冲区。一个正在播放,另一个正在更新:
if ( FWaveDataProvider->IsStreaming() )
{
FBuffersCount = 2;
alGenBuffers( FBuffersCount, &FBufferID[0] );
}
else
如果附加的波形不是流式,或者更具体地说,它不是压缩的,我们会创建一个单一缓冲区并将所有数据复制到其中:
{
FBuffersCount = 1;
alGenBuffers( FBuffersCount, &FBufferID[0] );
alBufferData( FBufferID[0],
FWaveDataProvider->GetALFormat(),
FWaveDataProvider->GetWaveData(),
( int )FWaveDataProvider->GetWaveDataSize(),
FWaveDataProvider->FSamplesPerSec );
alSourcei( FSourceID, AL_BUFFER, FBufferID[0] );
}
}
私有方法 UnqueueAll()
使用 alSourceUnqueueBuffers()
来清除 OpenAL 播放队列:
private:
void UnqueueAll()
{
int Queued;
alGetSourcei( FSourceID, AL_BUFFERS_QUEUED, &Queued );
if ( Queued > 0 )
{
alSourceUnqueueBuffers( FSourceID, Queued, &FBufferID[0] );
}
}
类的尾部部分定义了附加的 iWaveDataProvider
的引用,OpenAL 对象的内部句柄以及已分配缓冲区的数量:
clPtr<iWaveDataProvider> FWaveDataProvider;
unsigned int FSourceID;
unsigned int FBufferID[2];
int FBuffersCount;
};
为了展示一些基本的流式处理能力,我们更改了 1_InitOpenAL
的示例代码,并创建了一个带有附加音调发生器的音频源,如下代码所示:
class clSoundThread: public iThread
{
virtual void Run()
{
g_Audio.WaitForInitialization();
auto Src = make_intrusive<clAudioSource>();
Src->BindWaveform( make_intrusive<clToneGenerator>() );
Src->Play();
double Seconds = Env_GetSeconds();
while ( !IsPendingExit() )
{
float DeltaSeconds = static_cast<float>( Env_GetSeconds() - Seconds );
Src->Update( DeltaSeconds );
Seconds = Env_GetSeconds();
}
}
};
在此示例中,我们故意避免了解压缩声音的问题,以便专注于流式处理逻辑。因此,我们从程序生成的声音开始。clToneGenerator
类重写了 StreamWaveData()
方法并生成正弦波,即纯音调。为了避免可听见的故障,我们必须仔细采样正弦函数并记住最后一个生成样本的整数索引。这个索引存储在 FLastOffset
字段中,并在每次迭代中的计算中使用。
类的构造函数将音频参数设置为 16 位 44.1kHz,并在 FBuffer
容器中分配一些空间。这个音调的基本频率设置为 440 Hz:
class clToneGenerator : public clStreamingWaveDataProvider
{
public:
clToneGenerator()
: FFrequency( 440.0f )
, FAmplitude( 350.0f )
, FLastOffset( 0 )
{
FBufferUsed = 100000;
FBuffer.resize( 100000 );
FChannels = 2;
FSamplesPerSec = 44100;
FBitsPerSample = 16;
}
在 StreamWaveData()
中,我们检查 FBuffer
向量中是否有可用空间,并在必要时重新分配它:
virtual int StreamWaveData( int Size )
{
if ( Size > static_cast<int>( FBuffer.size() ) )
{
FBuffer.resize( Size );
LastOffset = 0;
}
最后,我们计算音频样本。频率会根据样本数量重新计算:
const float TwoPI = 2.0f * 3.141592654f;
float Freq = TwoPI * FFrequency /
static_cast<float>( FSamplesPerSec );
由于我们需要 Size
字节,并且我们的信号包含两个声道,每个声道 16 位样本,因此我们需要总共 Size/4
个样本:
for ( int i = 0 ; i < Size / 4 ; i++ )
{
float t = Freq * static_cast<float>( i + LastOffset );
float val = FAmplitude * std::sin( t );
我们将浮点数值转换为 16 位有符号整数,并将此整数的低字节和高字节放入 FBuffer
中。对于每个声道,我们存储两个字节:
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;
}
计算后,我们增加样本计数并取余数,以避免计数器中的整数溢出:
LastOffset += Size / 4;
LastOffset %= FSamplesPerSec;
return ( FBufferUsed = Size );
}
float FFrequency;
float FAmplitude;
private:
int LastOffset;
};
编译后的示例将产生一个 440 Hz 的纯音调。我们鼓励您更改 clToneGenerator::FFrequency
的值,看看它是如何工作的。您甚至可以使用此示例为您的乐器创建一个简单的音叉应用程序。至于乐器,让我们生成一些模仿弦乐器的音频数据。
弦乐器的音乐模型
让我们使用前一个示例的代码来实现一个简单的弦乐器物理模型。稍后你可以使用这些例程为 Android 创建一个小型的交互式合成器。
弦被建模为一系列垂直振动的点质量。严格来说,我们求解具有特定初始和边界条件的线性一维波动方程。声音是通过在声音接收位置取得解的值来产生的。
我们需要clGString
类来存储所有的模型值和最终结果。GenerateSound()
方法会预先计算字符串参数,并相应地调整数据容器的大小:
class clGString
{
public:
void GenerateSound()
{
// 4 seconds, 1 channel, 16 bit
FSoundLen = 44100 * 4 * 2;
FStringLen = 200;
Frc
值是声音的规范化基频。泛音是由物理模型隐式创建的:
float Frc = 0.5f;
InitString( Frc );
FSamples.resize( FsoundLen );
FSound.resize( FsoundLen );
float MaxS = 0;
在初始化阶段之后,我们通过在循环中调用Step()
方法来执行波动方程的积分。Step()
成员函数返回弦在接收位置处的位移:
for ( int i = 0; i < FSoundLen; i++ )
{
FSamples[i] = Step();
在每一步,我们将值限制在最大值:
if ( MaxS < fabs(FSamples[i]) )
MaxS = fabs( FSamples[i] );
}
最后,我们将浮点数值转换为有符号短整型。为了避免溢出,每个样本都要除以MaxS
的值:
const float SignedShortMax = 32767.0f;
float k = SignedShortMax / MaxS;
for ( int i = 0; i < FSoundLen; i++ )
{
FSound [i] = FSamples [i] * k;
}
}
std::vector<short int> FSound;
private:
int FPickPos;
int FSoundLen;
std::vector<float> FSamples;
std::vector<float> FForce;
std::vector<float> FVel;
std::vector<float> FPos;
float k1, k2;
int FStringLen;
void InitString(float Freq)
{
FPos.resize(FStringLen);
FVel.resize(FStringLen);
FForce.resize(FStringLen);
const float Damping = 1.0f / 512.0f;
k1 = 1 - Damping;
k2 = Damping / 2.0f;
我们将声音接收器放置在靠*末尾的位置:
FPickPos = FStringLen * 5 / 100;
for ( int i = 0 ; i < FStringLen ; i++ )
{
FVel[i] = FPos[i] = 0;
}
为了获得更好的结果,我们在弦元素的质地上产生轻微的变化:
for ( int i = 1 ; i < FStringLen - 1 ; i++ )
{
float m = 1.0f + 0.5f * (frand() - 0.5f);
FForce[i] = Freq / m;
}
在开始时,我们为弦的第二部分设置非零速度:
for ( int i = FStringLen/2; i < FStringLen - 1; i++ )
{
FVel[i] = 1;
}
}
frand()
成员函数返回 0..1 范围内的伪随机浮点值:
inline float frand()
{
return static_cast<float>( rand() ) / static_cast<float>( RAND_MAX );
}
注意
如果你的编译器支持,使用std::random
是获取伪随机数的首选方式。
这是使用新的 C++11 标准库生成 0…1 范围内均匀分布的伪随机浮点数的方法:
std::random_device rd;
std::mt19937 gen( rd() );
std::uniform_real_distribution<> dis( 0.0, 1.0 );
float frand()
{
return static_cast<float>( dis( gen ) );
}
尽管这段简短的代码片段在我们的源代码包中未使用,但它可能对你有用。让我们回到我们示例的代码。
Step()
方法进行单步操作并整合弦运动的方程。在步骤结束时,从FPos
向量在FPickPos
位置的值作为声音的下一个样本。对于熟悉数值方法的读者来说,可能看起来很奇怪,因为没有指定时间步长,它是隐式为 1/44100 秒的:
float Step()
{
首先,我们强制施加边界条件,即弦两端的固定端点:
FPos[0] = FPos[FStringLen - 1] = 0;
FVel[0] = FVel[FStringLen - 1] = 0;
根据胡克定律(en.wikipedia.org/wiki/Hooke's_law
),力与伸长量成正比:
for ( int i = 1 ; i < FStringLen - 1 ; i++ )
{
float d = (FPos[i - 1] + FPos[i + 1]) * 0.5f - FPos[i];
FVel[i] += d * FForce[i];
}
为了确保数值稳定性,我们应用一些人工阻尼,并取相邻速度的*均值。如果不这样做,会产生一些不想要的声音:
for ( int i = 1 ; i < FStringLen - 1 ; i++ )
{
FVel[i] = FVel[i] * k1 +
(FVel[i - 1] + FVel[i + 1]) * k2;
}
最后,我们更新位置:
for ( int i = 1 ; i < FStringLen ; i++ )
{
FPos[i] += FVel[i];
}
为了记录我们的声音,我们只取弦的一个位置:
return FPos[FPickPos];
}
};
1_InitOpenAL
示例可以轻松修改,以生成字符串声音,而不是加载.wav
文件。我们创建clGString
实例并调用GenerateSound()
方法。之后,我们获取FSound
向量并将其提交给音频源的PlayBuffer()
方法:
clGString String;
String.GenerateSound();
const unsigned char* Data = (const unsigned char*)&String.FSound[0];
PlayBuffer( Data, (int)String.FSound.size() );
在这里,采样率被硬编码为 44100 Hz。尝试3_GuitarStringSound
示例以获取完整代码并亲自聆听。请注意,由于在播放声音之前需要进行大量预计算,启动时间可能会稍长。然而,代码非常简单,我们将其作为一个练习留给读者,让他们为 Android 编译,并从后续示例中获取所有必要的 makefile 和包装器。同时,我们将处理那些可以立即在 Android 上运行的内容。
解码压缩音频
现在我们已经实现了基本的音频流系统,是时候使用几个第三方库来读取压缩的音频文件了。基本上,我们需要做的是覆盖clStreamingWaveDataProvider
类中的StreamWaveData()
函数。这个函数反过来调用ReadFromFile()
方法,实际解码就在这里完成。解码器的初始化在构造函数中进行,对于抽象的iDecodingProvider
类,我们只存储对数据块引用。文件的所有压缩数据都存储在clBlob
对象中:
class iDecodingProvider: public StreamingWaveDataProvider
{
protected:
virtual int ReadFromFile( int Size, int BytesRead ) = 0;
clPtr<clBlob> FRawData;
public:
bool FLoop;
bool FEof;
iDecodingProvider( const clPtr<clBlob>& Blob )
: FRawData( Blob )
, FLoop( false )
, FEof( false )
{}
virtual bool IsEOF() const { return FEof; }
StreamWaveData()
方法负责解码工作。前几行确保FBuffer
有足够的空间来包含解码后的数据:
virtual int StreamWaveData( int Size ) override
{
int OldSize = ( int )FBuffer.size();
if ( Size > OldSize )
{
重新分配缓冲区后,我们用零填充新字节,因为非零值可能会产生意外的噪音:
FBuffer.resize( Size, 0 );
}
if ( FEof ) { return 0; }
由于ReadFromFile()
可能会返回不充分的数据,我们以循环的方式调用它,并增加读取的字节数:
int BytesRead = 0;
while ( BytesRead < Size )
{
int Ret = ReadFromFile( Size, BytesRead );
if ( Ret > 0 ) BytesRead += Ret;
ReadFromFile()
返回零意味着我们已达到流末尾:
else if ( Ret == 0 )
{
FEof = true;
通过调用Seek()
并设置FEof
标志来实现循环:
if ( FLoop )
{
Seek( 0 );
FEof = false;
continue;
}
break;
}
Ret
中的负值表示发生了读取错误。在这种情况下,我们停止解码:
else
{
Seek( 0 );
FEof = true;
break;
}
}
return ( FBufferUsed = BytesRead );
}
};
接下来的两节将展示如何使用流行的第三方库解码不同格式的音频文件。
使用 ModPlug 库解码跟踪器音乐
我们将要处理的第一个用于解码音频文件的库是 Olivier Lapicque 的 ModPlug 库。大多数流行的跟踪器音乐文件格式en.wikipedia.org/wiki/Module_file
可以使用 ModPlug 解码并转换为适合 OpenAL 的波形。我们将介绍实现ReadFromFile()
例程的clModPlugProvider
类。该类的构造函数将内存块加载到ModPlugFile
对象中,并分配默认的音频参数:
class clModPlugProvider: public iDecodingProvider
{
private:
ModPlugFile* FModFile;
public:
ModPlugProvider( const clPtr<clBlob>& Blob ):
{
DecodingProvider( Blob )
FChannels = 2;
FSamplesPerSec = 44100;
FBitsPerSample = 16;
FModFile = ModPlug_Load_P(
( const void* ) FRawData->GetDataConst(), ( int )FRawData->GetSize()
);
}
析构函数清理 ModPlug:
virtual ~ModPlugProvider() { ModPlug_Unload_P( FModFile ); }
ReadFromFile()
方法调用ModPlug_Read()
来填充FBuffer
:
virtual int ReadFromFile( int Size, int BytesRead )
{
return ModPlug_Read_P( FModFile,
&FBuffer[0] + BytesRead, Size - BytesRead );
}
流定位是通过使用ModPlug_Seek()
例程完成的。在 ModPlug API 内部,所有的时间计算都是以毫秒为单位的:
virtual void Seek( float Time )
{
FEof = false;
ModPlug_Seek_P( FModFile, ( int )( Time * 1000.0f ) );
}
};
要使用这个波形数据提供者,我们将其实例附加到clAudioSource
对象:
Src->BindWaveform( make_intrusive<clModPlugProvider>( LoadFileAsBlob( g_FS, "augmented_emotions.xm" )
)
);
其他细节是从我们之前的示例中复用的。4_ModPlug
文件夹可以在 Android 和 Windows 上构建和运行。使用ndk-build
和ant debug
为 Android 创建.apk
,使用make all
创建 Windows 可执行文件。
解码 MP3 文件
MPEG-1 Layer 3 格式的多数专利在 2015 年底到期,因此值得提及 Fabrice Bellard 的 MiniMP3 库。使用这个库不会比 ModPlug 更难,因为我们已经在iDecodingProvider
中完成了所有繁重的工作。让我们看看5_MiniMP3
示例。clMP3Provider
类创建了解码器实例,并通过读取开头的几帧来读取流参数:
class clMP3Provider: public iDecodingProvider
{
public:
clMP3Provider( const clPtr<clBlob>& Blob )
: iDecodingProvider( Blob )
{
FBuffer.resize(MP3_MAX_SAMPLES_PER_FRAME * 8);
FBufferUsed = 0;
FBitsPerSample = 16;
mp3 = mp3_create();
bytes_left = ( int )FRawData->GetSize();
一开始,我们将流位置设置为clBlob
对象的开始处:
stream_pos = 0;
byte_count = mp3_decode((mp3_decoder_t*)mp3,
( void* )FRawData->GetData(), bytes_left,
(signed short*)&FBuffer[0], &info);
bytes_left -= byte_count;
我们需要关于音频数据的信息,因此我们从info
结构中获取它:
FSamplesPerSec = info.sample_rate;
FChannels = info.channels;
}
析构函数中没有特别之处,以下是它的样子:
virtual ~MP3Provider()
{
mp3_done( &mp3 );
}
ReadFromFile()
方法跟踪源流中剩余的字节数,并填充FBuffer
容器。构造函数和这个方法都使用bytes_left
和stream_pos
字段来保持当前的流位置和剩余的字节数:
virtual int ReadFromFile( int Size, int BytesRead )
{
byte_count = mp3_decode( (mp3_decoder_t*)mp3, (( char* )FRawData->GetData()) + stream_pos, bytes_left, (signed short *)(&FBuffer[0] + BytesRead), &info);
bytes_left -= byte_count;
stream_pos += byte_count;
return info.audio_bytes;
}
对于可变比特率的流,寻道并不是那么明显,因此我们将这个实现留给感兴趣的读者作为一个练习。在固定比特率的最简单情况下,只需从秒重新计算Time
到采样率单位,然后设置stream_pos
变量:
virtual void Seek( float Time ) override
{
FEof = false;
}
private:
mp3_decoder_t mp3;
mp3_info_t info;
int stream_pos;
int bytes_left;
int byte_count;
};
要使用它,我们将提供者附加到clAudioSource
对象,就像使用 ModPlug 一样:
Src->BindWaveform( make_intrusive<clMP3Provider>( LoadFileAsBlob( g_FS, "test.mp3" ) ) );
同样,这个示例可以在 Android 上运行,去试试吧。
注意
这段代码没有正确处理一些 ID3 标签。如果你想基于我们的代码编写一个通用的音乐播放器,可以参考作者编写的这个开源项目:github.com/corporateshark/PortAMP
。
解码 OGG 文件
还有一个值得提及的流行音频格式。Ogg Vorbis 是一种完全开放、无专利、专业的音频编码和流媒体技术,具有开源的所有好处www.vorbis.com
。OGG 解码和播放过程的大致流程与 MP3 类似。让我们看看示例6_OGG
。Decoders.cpp
文件用 OGG Vorbis 函数的定义进行了扩展,包括OGG_clear_func()
、OGG_open_callbacks_func()
、OGG_time_seek_func()
、OGG_read_func()
、OGG_info_func()
和OGG_comment_func()
。这些函数在 Android 上链接到一个静态库,或者在 Windows 上从.dll
文件加载。与 MiniMP3 API 的主要区别在于向 OGG 解码器提供一组数据读取回调。这些回调在OGG_Callbacks.inc
文件中实现。OGG_ReadFunc()
回调将数据读取到解码器中:
static size_t OGG_ReadFunc( void* Ptr, size_t Size, size_t NMemB, void* DataSource )
{
clOggProvider* OGG = static_cast<clOggProvider*>( 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, ( unsigned char* )OGG->FRawData->GetDataConst() +
OGG->FOGGRawPosition, ( size_t )BytesRead );
OGG->FOGGRawPosition += BytesRead;
return ( size_t )BytesRead;
}
OGG_SeekFunc()
回调使用不同的相对定位模式来查找输入流:
static int OGG_SeekFunc( void* DataSource, ogg_int64_t Offset, int Whence )
{
clOggProvider* OGG = static_cast<clOggProvider*>( 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()
和 OGG_TellFunc()
函数非常简单:
static int OGG_CloseFunc( void* DataSource )
{
return 0;
}
static long OGG_TellFunc( void* DataSource )
{
return static_cast<int>(
(( clOggProvider* )DataSource )->FOGGRawPosition );
}
这些回调在 clOggProvider
的构造函数中使用,以设置解码器:
clOggProvider( const clPtr<clBlob>& Blob )
: iDecodingProvider( Blob )
, FOGGRawPosition( 0 )
{
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, nullptr, -1, Callbacks );
流参数(如通道数、采样率和每样本位数)在这里获取:
vorbis_info* VorbisInfo = OGG_ov_info ( &FVorbisFile, -1 );
FChannels = VorbisInfo->channels;
FSamplesPerSec = VorbisInfo->rate;
FBitsPerSample = 16;
}
析构函数非常简单:
virtual ~clOggProvider()
{
OGG_ov_clear( &FVorbisFile );
}
ReadFromFile()
和 Seek()
方法在精神上与我们处理 MiniMP3 时所做的非常相似:
virtual int ReadFromFile( int Size, int BytesRead ) override
{
return ( int )OGG_ov_read( &FVorbisFile, &FBuffer[0] + BytesRead, Size - BytesRead, 0, FBitsPerSample / 8, 1, &FOGGCurrentSection );
}
virtual void Seek( float Time ) override
{
FEof = false;
OGG_ov_time_seek( &FVorbisFile, Time );
}
private:
这是在前面章节提到的回调函数定义的地方。当然,它们可以在原地定义,而不必将它们移到单独的文件中。然而,我们认为这种分离对于本例来说在逻辑上更为清晰;将数据提供者概念和 OGG Vorbis
相关 API 逻辑上分开:
#include "OGG_Callbacks.inc"
OggVorbis_File FVorbisFile;
ogg_int64_t FOGGRawPosition;
int FOGGCurrentSection;
};
这个示例也开箱即用,支持 Android。运行以下命令以在您的设备上获取 .apk
:
>ndk-build
>ant debug
>adb install -r bin/App1-debug.apk
现在启动活动,享受音乐吧!在后续章节中,我们将在本章内容的基础上添加更多有趣的音频内容。
总结
在本章中,我们学习了如何使用可移植的 C++ 代码和开源第三方库在 Android 上播放音频。提供的示例能够播放 .mp3
和 .ogg
音频文件以及 .it
、.xm
、.mod
和 .s3m
模块。我们还学习了如何生成自己的波形来模拟乐器。代码可以在许多系统间移植,并且可以在 Android 和 Windows 上运行和调试。现在,我们已经完成了音频部分,是时候进入下一章,使用 OpenGL 渲染一些图形了。
第六章:OpenGL ES 3.1 与跨*台渲染
在本章中,我们将学习如何实现在 OpenGL 4 和 OpenGL ES 3 之上的抽象层,以便让我们的图形应用程序能在 Android 和桌面计算机上运行。首先,我们从一些基本的向量与线性代数类开始。
线性代数与变换
在Core/VecMath.h
文件中,有一系列针对向量和矩阵的特定类和辅助工具。我们主要使用的类是LVector2
、LVector3
、LVector4
、LMatrix3
、LMatrix4
和LQuaternion
,这些类定义了基本的代数运算。它们有一些快捷方式,以便编写任何数学密集型代码:
using vec2 = LVector2;
using vec3 = LVector3;
using vec4 = LVector4;
using mat3 = LMatrix3;
using mat4 = LMatrix4;
using quat = LQuaternion;
这个小型数学库基本上是从 Linderdaum Engine (www.linderdaum.com
)压缩的一些代数代码。
此外,在Math
命名空间中有一组有用的函数,用于处理不同的投影变换计算。在后续章节中,它们将被大量使用。
使用 SDL2 进行图形初始化
在我们之前的书籍《Android NDK Game Development Cookbook, Packt Publishing》中,我们详细学习了如何初始化 Android 上的 OpenGL ES 2 和桌面上的 OpenGL 3 核心配置。现在,我们将使用 SDL2 库来完成这项工作,该库可在www.libsdl.org
获取。让我们看看1_GLES3
示例。这个示例的 Java 代码(除了 SDL2 内部实现之外)简短且简单:
package com.packtpub.ndkmastering;
import android.app.Activity;
import android.os.Bundle;
public class AppActivity extends org.libsdl.app.SDLActivity
{
static
{
System.loadLibrary( "NativeLib" );
}
public static AppActivity m_Activity;
@Override protected void onCreate( Bundle icicle )
{
super.onCreate( icicle );
m_Activity = this;
}
};
其他所有操作都在 C++代码中完成。有一个main()
函数,它通过 SDL2 使用宏重新定义,使我们的应用程序看起来像是桌面应用程序:
int main(int argc, char* argv[])
{
clSDL SDLLibrary;
首先,使用clSDLWindow
类创建一个窗口和一个 OpenGL 渲染上下文:
g_Window = clSDLWindow::CreateSDLWindow( "GLES3", 1024, 768 );
然后,我们可以获取 OpenGL 函数的指针。这种抽象比静态链接到 OpenGL 库更优越,因为它使我们的代码更具可移植性。例如,在 Windows 上,如果不使用第三方库,你不能静态链接到核心 OpenGL 函数:
LGL3 = std::unique_ptr<sLGLAPI>( new sLGLAPI() );
LGL::GetAPI( LGL3.get() );
这是我们在第四章,组织虚拟文件系统中处理虚拟文件系统时已经使用的回调。在这个例子中我们不需要任何路径,所以让我们使用一个空字符串:
OnStart( "" );
事件循环是显式完成的,并包含对OnDrawFrame()
函数的调用:
while( g_Window && g_Window->HandleInput() )
{
OnDrawFrame();
g_Window->Swap();
}
g_Window = nullptr;
return 0;
}
这些包装类(clSDL
和clSDLWindow
)分别声明在SDLLibrary.h
和SDLWindow.h
文件中。clSDL
类是基于 SDL 的 RAII 包装器,在构造函数和析构函数中进行库的初始化和反初始化:
clSDL()
{
SDL_Init( SDL_INIT_VIDEO );
}
virtual ~clSDL()
{
SDL_Quit();
}
clSDLWindow
类表示一个带有 OpenGL 上下文和系统消息泵的窗口抽象:
class clSDLWindow: public iIntrusiveCounr
{
private:
SDL_Window* m_Window;
SDL_GLContext m_Context;
float m_Width;
float m_Height;
std::atomic<bool> m_Pendingit;
public:
clSDLWindow( const std::string& Title, int Width, int Height );
virtual ~clSDLWindow();
void RequestExit()
{
m_PendingExit = true;
}
void Swap();
这个成员函数执行消息循环的一次迭代:
bool HandleInput()
{
SDL_Event Event;
while ( SDL_PollEvent(&Event) && !m_PendingExit )
{
if ( (Event.type == SDL_QUIT) || !this->HandleEvent( Event ) )
m_PendingExit = true;
}
return !m_PendingExit;
}
将整数坐标转换为浮点数标准化坐标 0..1,以便更容易使用不同分辨率的屏幕:
vec2 GetNormalizedPoint( int x, int y ) const
{
return vec2(
static_cast<float>(x) / m_Width,
static_cast<float>(y) / m_Height
);
}
以下方法对于构建当前窗口的投影矩阵很有用:
float GetAspect() const
{
return m_Width / m_Height;
}
一个公共静态辅助方法,用于创建clSDLWindow
的实例:
public:
static clPtr<clSDLWindow> CreateSDLWindow(
const std::string& Title, int Width, int Height )
{
return make_intrusive<clSDLWindow>( Title, Width, Height );
}
HandleEvent()
成员函数负责将 SDL2 事件分派给我们的回调函数:
private:
bool HandleEvent( const SDL_Event& Event );};
HandleEvent()
的实现如下:
bool clSDLWindow::HandleEvent( const SDL_Event& Event )
{
switch ( Event.type )
{
case SDL_WINDOWEVENT:
if ( Event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED)
{
m_Width = static_cast<float>( Event.window.data1 );
m_Height = static_cast<float>( Event.window.data2 );
}
return true;
case SDL_KEYDOWN:
case SDL_KEYUP: OnKey( Event.key.keysym.sym, Event.type == SDL_KEYDOWN ); break;
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP: break;
case SDL_MOUSEMOTION break;
case SDL_MOUSEWHEEL break;
}
return true;
}
并非所有的案例标签都已实现,也并非所有的 SDL2 事件都被使用。我们将在后续章节根据需要使用这个路由。
在我们的示例中,我们使用一些有用的 OpenGL 包装器渲染一个旋转的盒子,可以隐藏移动版和桌面版 OpenGL 之间的差异。以下是OnStart()
的代码,它将 OpenGL 的版本打印到系统日志中,并初始化顶点缓冲对象和着色器程序:
clPtr<clVertexAttribs> g_Box;
clPtr<clGLVertexArray> g_BoxVA;
clPtr<clGLSLShaderProgram> g_ShaderProgram;
void OnStart( const std::string& RootPath )
{
LOGI( "Hello Android NDK!" );
const char* GLVersion = (const char*)LGL3->glGetString( GL_VERSION );
const char* GLVendor = (const char*)LGL3->glGetString( GL_VENDOR );
const char* GLRenderer = (const char*)LGL3->glGetString( GL_RENDERER );
LOGI( "GLVersion : %s\n", GLVersion );
LOGI( "GLVendor : %s\n", GLVendor );
LOGI( "GLRenderer: %s\n", GLRenderer );
首先,我们创建一个与 API 无关的盒子网格表示:
g_Box = clGeomServ::CreateAxisAlignedBox( LVector3(-1), LVector3(+1) );
然后,我们将其输入到 OpenGL 中,使用顶点缓冲对象创建一个顶点数组:
g_BoxVA = make_intrusive<clGLVertexArray>();
g_BoxVA->SetVertexAttribs( g_Box );
着色器程序由包含顶点和片段着色器源代码的两个字符串变量构建而成:
g_ShaderProgram = make_intrusive<clGLSLShaderProgram>( g_vShaderStr, g_fShaderStr );
LGL3->glClearColor( 0.1f, 0.0f, 0.0f, 1.0f );
LGL3->glEnable( GL_DEPTH_TEST );
}
下面是使用 GLSL 3.3 核心配置编写的着色器。使用模型-视图-投影矩阵变换顶点:
static const char g_vShaderStr[] = R"(
uniform mat4 in_ModelViewProjectionMatrix;
in vec4 in_Vertex;
in vec2 in_TexCoord;
out vec2 Coords;
void main()
{
Coords = in_TexCoord.xy;
gl_Position = in_ModelViewProjectionMatrix * in_Vertex;
}
)";
使用纹理坐标作为 RG 颜色分量来绘制盒子:
static const char g_fShaderStr[] = R"(
in vec2 Coords;
out vec4 out_FragColor;
void main()
{
out_FragColor = vec4( Coords, 1.0, 1.0 );
}
)";
你可能已经注意到着色器的源代码不包含#version
和precision
行。这是因为clGLSLShaderProgram
类对源代码进行了一些操作,以抽象不同版本 GLSL 之间的差异。我们将在后续段落熟悉这个类。在此之前,让我们看看OnDrawFrame()
:
void OnDrawFrame()
{
static float Angle = 0;
Angle += 0.02f;
LGL3->glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );
mat4 Proj = Math::Perspective(
45.0f, g_Window->GetAspect(), 0.4f, 2000.0f );
绕(1, 1, 1)
轴旋转立方体:
LMatrix4 MV = LMatrix4::GetRotateMatrixAxis( Angle,
vec3( 1, 1, 1 ) ) *
mat4::GetTranslateMatrix( vec3( 0, 0, -5 ) );
g_ShaderProgram->Bind();
g_ShaderProgram->SetUniformNameMat4Array(
"in_ModelViewProjectionMatrix", 1, MV * Proj );
g_BoxVA->Draw( false );
}
OpenGL API 绑定
如你所见,前面提到的代码中所有的 OpenGL 调用都是通过LGL3
前缀完成的。这是一个在LGLAPI.h
中声明的名为sLGLAPI
的结构,包含指向实际 OpenGL API 函数的指针:
struct sLGLAPI
{
sLGLAPI()
{
memset( this, 0, sizeof( *this ) );
};
PFNGLACTIVETEXTUREPROC glActiveTexture;
PFNGLATTACHSHADERPROC glAttachShader;
PFNGLBINDATTRIBLOCATIONPROC glBindAttribLocation;
PFNGLBINDBUFFERPROC glBindBuffer;
PFNGLBINDBUFFERBASEPROC glBindBufferBase;
PFNGLBINDFRAGDATALOCATIONPROC glBindFragDataLocation;
...
}
sLGLAPI
结构的字段在LGL::GetAPI()
函数中设置。这个函数有两个不同的实现,一个是 Windows 的LGL_Windows.h
,另一个是LGL_Android.h
中为其他所有*台。区别在于 Windows 上的动态链接,如下代码所示:
void LGL::GetAPI( sLGLAPI* API )
{
API->glBlendFunc = ( PFNGLBLENDFUNCPROC )GetGLProc( API, "glBlendFunc" );
API->glBufferData = ( PFNGLBUFFERDATAPROC )GetGLProc( API, "glBufferData" );
API->glBufferSubData = ( PFNGLBUFFERSUBDATAPROC )GetGLProc( API, "glBufferSubData");
...
}
所有其他*台都使用静态链接系统提供的 OpenGL 库:
void LGL::GetAPI( sLGLAPI* API )
{
API->glActiveTexture = &glActiveTexture;
API->glAttachShader = &glAttachShader;
API->glBindAttribLocation = &glBindAttribLocation;
API->glBindBuffer = &glBindBuffer;
...
}
当然,如果你使用特定供应商的 OpenGL 扩展,可以使用动态链接在任何*台上通过glGetProcAddresss()
访问它们,这时sLGLAPI
结构就显得非常方便:
这是我们在 OpenGL 之上的抽象层次最低的部分。有人可能会说这个所谓的层次什么也不做。这是不正确的。看看在 Android 上是如何获取glClearDepth()
的指针的。出于某种原因,不是直接调用函数,而是一个存根:
API->glClearDepth = &Emulate_glClearDepth;
存根定义如下:
LGL_CALL void Emulate_glClearDepth( double Depth )
{
glClearDepthf( static_cast<float>( Depth ) );
}
原因是 OpenGL ES 中没有glClearDepth()
函数,它接受一个float
参数,但 OpenGL 3 有这个函数。这种方式可以将移动设备和桌面 OpenGL 之间的 API 差异隐藏在薄薄的抽象层后面。使用这种技术,你可以透明地替换一个 OpenGL 枚举为另一个。可以透明地实现跟踪机制,将 OpenGL 函数参数的值打印到日志中。在将现有应用程序移植到没有图形调试器可用的*台时,这种技术至关重要(是的,我们在说你,黑莓)。我们将这个作为你的练习。
现在让我们深入了解高级抽象是如何实现的。
跨*台的 OpenGL 抽象概念
几何对象可以通过它们的表面来表示。在本章中,我们只讨论多边形图形,因此最重要的数据结构是三角网格。
就像数字音频一样,我们方便的 API 无关的数据结构在可以渲染之前需要转换成图形 API 的本地格式。让我们从 3D 空间中三角化几何的表示开始。
一个单一三角形可以通过三个顶点来指定。每个顶点至少存储其在 3D 空间中的位置,如下所示:
在实现可移植渲染器的第一步,我们需要将几何存储分离出来,最简单的情况下,这只是带有属性和顶点的集合以及通过这些顶点构造图形原语的迭代顺序,与任何 API 特定的函数和数据类型分离。这种数据结构在clVertexAttribs
类中实现:
class clVertexAttribs: public iIntrusiveCounter
{
public:
clVertexAttribs();
explicit clVertexAttribs( size_t Vertices );
void SetActiveVertexCount( size_t Count )
{FActiveVertexCount = Count; }
size_t GetActiveVertexCount() const
{ return FActiveVertexCount; }
这个方法返回一个包含指向实际顶点属性、位置、纹理坐标、法线和颜色的容器,可以输入到 OpenGL 顶点缓冲对象中:
const std::vector<const void*>& EnumerateVertexStreams() const;
{
FStreams[ L_VS_VERTEX ] = &FVertices[0];
FStreams[ L_VS_TEXCOORD ] = &FTexCoords[0];
FStreams[ L_VS_NORMAL ] = &FNormals[0];
FStreams[ L_VS_COLORS ] = &FColors[0];
return FStreams;
}
我们声明了一组辅助方法来生成几何数据:
void Restart( size_t ReserveVertices );
void EmitVertexV( const vec3& Vec );
void SetTexCoordV( const vec2& V );
void SetNormalV( const vec3& Vec );
void SetColorV( const vec4& Vec );
我们声明一组公共字段来存储我们的数据。顶点 3D 位置x,y,z声明如下:
public:
std::vector<vec3> FVertices;
纹理坐标u
和v
。这是我们顶点格式的局限性,因为有时纹理坐标可能包含超过两个通道。然而,对于我们的应用程序来说,这种限制是合适且可行的:
std::vector<vec2> FTexCoords;
顶点法线通常在对象空间中:
std::vector<vec3> FNormals;
顶点的 RGBA 颜色。如果你编写了正确的着色器,这个容器可以用于任何你想要的定制数据:
std::vector<vec4> FColors;
};
实现很简单;但是,我们建议在进一步操作之前查看Geometry.cpp
和Geometry.h
文件。
为了将有用的数据填充到clVertexAttribs
的实例中,clGeomServ
类中声明了一组静态方法:
classlGeomServ
{
public:
static clPtr<clVertexAttribs> CreateTriangle2D( float vX, float vY, float dX, float dY, float Z );
static clPtr<clVertexAttribs> CreateRect2D( float X1, float Y1, float X2, float Y2, float Z,
bool FlipTexCoordsVertical, int Subdivide );
static void AddAxisAlignedBox( const clPtr<clVertexAttribs>& VA, const LVector3& Min, const LVector3& Max );
static clPtr<clVertexAttribs> CreateAxisAlignedBox( const LVector3& Min, const LVector3& Max );
static void AddPlane( const clPtr<clVertexAttribs>& VA, float SizeX, float SizeY, int SegmentsX, int SegmentsY, float Z );
static clPtr<clVertexAttribs> CreatePlane( float SizeX, float SizeY, int SegmentsX, int SegmentsY, float Z );
};
所有Create*()
方法创建一个新的几何图元并返回包含它的clVertexAttribs
实例。以Add
开头的方法将图元添加到现有的clVertexAttribs
类实例中,假设它有足够的容量来存储新的图元。实现非常简单,可以在Geometry.cpp
中找到。更复杂的几何生成例程将在后续章节中添加。
将几何数据提供给 OpenGL
要渲染clVertexAttribs
的内容,我们需要将其数据转换为一组特定于 API 的缓冲区和 API 函数调用。这是通过在clGLVertexArray
类中创建顶点数组对象(VOA)和顶点缓冲区对象(VBO)OpenGL 对象,并从clVertexAttribs
获取内容来完成的:
class clGLVertexArray: public iInusiveCounter
{
public:
clGLVertexArray();
virtual ~clGLVertexArray();
Draw()
方法执行实际渲染,它是我们抽象层中可能进行渲染的最低级别:
void Draw( bool Wireframe ) const;
void SetVertexAttribs(
const clPtr<clVertexAttrs>& Attribs );
private:
void Bind() const;
private:
Luint FVBOID;
Luint FVAOID;
这些指针实际上是顶点数据在顶点缓冲区内的偏移量:
std::vector<const void*> FAttribVBOOffset;
这些指针指向clVertexAttribs
中的实际数据:
std::vector<const void*> FEnumeratedStreams;
clPtr<clVertexAttribs> FAttribs;
};
这个类的实现包括一些簿记工作以及调用 OpenGL 函数。构造函数和析构函数分别初始化和销毁 VOA 和 VBO 的句柄:
clGLVertexArray::clGLVertexArray()
: FVBOID( 0 ),
FVAOID( 0 ),
FAttribVBOOffset( L_VS_TOTAL_ATTRIBS ),
FEnumeratedStreams( L_VS_TOTAL_ATTRIBS ),
FAttribs( nullptr )
{
在 Windows 上,我们使用 OpenGL 4,其中使用顶点数组对象是强制性的:
#if dined( _WIN32 )
LGL3->glGenVertexArrays( 1, &FVAOID );
#endif
}
销毁操作以特定于*台的方式进行:
clGLVertexArray::~clGLVertexArray()
{
LGL3->glDeleteBuffers( 1, &FVBOID );
#if defined( _WIN32 )
LGL3->glDeleteVertexArrays( 1, &FVAOID );
#endif
}
私有方法Bind()
将此顶点数组对象设置为 OpenGL 渲染管线的源顶点流:
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()
方法绑定 VOA 并调用glDrawArrays()
来渲染几何图形:
void clGLVertexArray::Draw( bool Wireframe ) const
{
#if defined( _WIN32 )
LGL3->glBindVertexArray( FVAOID );
#else
Bind();
#endif
第一个参数是图元的类型。如果Wireframe
参数为true
,我们告诉 OpenGL 将数据视为一系列线,每个连续的点对一条线。如果参数为false
,则每个连续的点三元组被用作三角形的三个顶点:
LGL3->glDrawArrays(
Wireframe ? GL_LINE_LOOP : GL_TRIANGLES, 0,
static_cast<GLsizei>( FAttribs->GetActiveVertexCount() ) );
}
SetVertexAttribs()
成员函数将几何数据附加到GLVertexArray
并重新创建所有必需的 OpenGL 对象:
void clGLVertexArray::SetVertexAttribs( const clPtr<clVertexAttribs>& Attribs )
{
FAttribs = Attribs;
分配指针后,我们获取一个指向各个顶点属性流的指针数组:
FEnumeratedStreams = FAttribs->EnumerateVertexStreams();
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 );
这里最重要的事情是将数据从clVertexAttribs
对象复制到 GPU 内存中。这是通过使用nullptr
作为缓冲区指针调用glBufferData()
来分配存储来完成的:
LGL3->glBufferData( GL_ARRAY_BUFFER, DataSize, nullptr, GL_STREAM_DRAW );
你可以在www.khronos.org/opengles/sdk/docs/man3/html/glBufferData.xhtml
找到更多关于glBufferData()
的信息。
这里是对每个非空属性数组的后续glBufferSubData()
调用,这些属性数组包括顶点位置、纹理坐标、法线和颜色:
for ( int i = 0; i != L_VS_TOTAL_ATTRIBS; i++ )
{
if ( FEnumeratedStreams[i] )
{
LGL3->glBufferSubData( GL_ARRAY_BUFFER, ( GLintptr )FAttribVBOOffset[ i ], FAttribs->GetActiveVertexCount() * sizeof( float ) * L_VS_VEC_COMPONENTS[ i ], FEnumeratedStreams[ i ] );
}
}
绑定对于 VAO 和非 VAO 版本来说有些特定:
#if defined( _WIN32 )
LGL3->glBindVertexArray( FVAOID );
Bind();
LGL3->glBindVertexArray( 0 );
#endif
}
VAO 版本可以在 OpenGL ES 3 上使用。然而,未经修改的代码也可以在 OpenGL ES 2 上运行。
着色器程序
桌面和移动 OpenGL 版本都将着色器程序作为其渲染管道的一部分。仅提供几何图形是不够的。然而,为了创建可移植的渲染子系统,我们应该处理 GLSL 3.00 ES 和 GLSL 3.30 Core 之间的几个重要区别。
让我们从uniform
值的声明开始:
struct sUniform
{
public:
explicit sUniform( const std::string& e)
: FName( e )
, FLocation( -1 )
{};
sUniform( int Location, const std::string& e) : FName( e )
, FLocation( Location )
{};
std::string FName;
Lint FLocation;
};
这个类存储了在链接着色器程序中统一变量的名称和位置。着色器程序类的结构如下所示:
class clGLSLShaderProgram: public iIntrusiveCounr
{
public:
构造函数以顶点和片段着色器的源代码作为参数:
clGLSLShaderProgram( const std::string& VShader, const std::string& FShader );
virtual ~clGLSLShaderProgram();
Bind()
方法在使用前绑定着色器程序:
void Bind();
一组处理统一变量的方法:
Lint CreateUniform( const std::string& Name );
void SetUniformNameFloat( const std::string& Name, const float Float );
void SetUniformNameFloatArray( const std::string& Name, int Count, const float& Float );
void SetUniformNameVec3Array( const std::string& Name, int Count, const LVector3& Vector );
void SetUniformNameVec4Array( const std::string& Name, int Count, const LVector4& Vector );
void SetUniformNameMat4Array( const std::string& Name, int Count, const LMatrix4& Matr );
private:
使用附加的着色器链接程序:
bool RelinkShaderProgram();
我们需要绑定属性和片段数据的默认位置。这将在以下方法中完成:
void BindDefaultLocations( Luint ProgramID )
{
LGL3->glBindAttribLocation( ProgramID, L_VS_VERTEX, "in_Vertex" );
LGL3->glBindAttribLocation( ProgramID, L_VS_TEXCOORD, "in_TexCoord" );
LGL3->glBindAttribLocation( ProgramID, L_VS_NORMAL, "in_Normal" );
LGL3->glBindAttribLocation( ProgramID, L_VS_COLORS, "in_Color" );
LGL3->glBindFragDataLocation( ProgramID, 0, "out_FragColor" );
}
它将着色器变量in_Vertex
、in_Normal
、in_TexCoord
和in_Color
绑定到适当的顶点流。你可以在你的 GLSL 代码中声明并使用这些in
变量。out_FragColor
输出变量与片段着色器的单一输出相关联。
编译并将着色器附加到此着色器程序:
Luint AttachShaderID( Luint Target, const std::string& ShaderCode, Luint OldShaderID );
检查编译和链接过程中发生的任何错误并记录:
bool CheckStatus( Luint ObjectID, Lenum Target, const std::string& Message ) const;
此方法从链接的着色器程序检索所有统一变量,并将它们作为sUniform
结构存储在FUniforms
容器中:
void RebindAllUniforms();
private:
std::string FVertexShader;
std::string FFragmentShader;
Luint FVertexShaderID;
Luint FFragmentShaderID;
此着色程序中所有活动的统一变量集合存储如下:
std::vector<sUniform> FUniforms;
OpenGL 着色器程序和着色器标识符存储在以下字段中:
Luint FProgramID;
std::vector<Luint> FShaderID;
};
clGLSLShaderProgram::clGLSLShaderProgram(
const std::string& VShader, const std::string& FShader )
: FVertexShader( VShader )
, FFragmentShader( FShader )
, FUniforms()
, FProgramID( 0 )
, FVertexShaderID( 0 )
, FFragmentShaderID( 0 )
{
RelinkShaderProgram();
}
我们可以如下销毁所有创建的 OpenGL 对象:
clGLSLShaderProgram::~clGLSLShaderProgram()
{
LGL3->glDeleteProgram( FProgramID );
LGL3->glDeleteShader( FVertexShaderID );
LGL3->glDeleteShader( FFragmentShaderID );
}
让我们看看如何创建着色器对象并将其附加到着色器程序:
Luint clGLSLShaderProgram::AttachShaderID( Luint Target,
const std::string& ShaderCode, Luint OldShaderID )
{
由于我们使用 OpenGL ES 3 和 OpenGL 4,着色器的版本应相应指定:
#if defined( USE_OPENGL_4 )
std::string ShaderStr = "#version 330 core\n";
#else
std::string ShaderStr = "#version 300 es\n";
ShaderStr += "precision highp float;\n";
ShaderStr += "#define USE_OPENGL_ES_3\n";
#endif
ShaderStr += ShaderCode;
生成的着色器提交给 OpenGL API 函数:
Luint Shader = LGL3->glCreateShader( Target );
const char* Code = ShaderStr.c_str();
LGL3->glShaderSource( Shader, 1, &Code, nullptr );
LGL3->glCompileShader( Shader );
检查编译状态,并记录编译代码时检测到的任何错误。如果新着色器编译失败,这段代码将回退到之前编译的着色器。你可以使用前几章中的文件系统类来实现动态着色器程序的重新加载:
if ( !CheckStatus( Shader, GL_COMPILE_STATUS, "Shader wasn''t compiled:" ) )
{
LGL3->glDeleteShader( Shader );
return OldShaderID;
}
if ( OldShaderID )
{
LGL3->glDeleteShader( OldShaderID );
}
return Shader;
}
错误检查和记录实现起来并不复杂,是必须的:
bool clGLSLShaderProgram::CheckStatus( Luint ObjectID, Lenum Target, const std::string& Message ) const
{
Lint SuccessFlag = 0;
Lsizei Length = 0;
Lsizei MaxLength = 0;
if ( LGL3->glIsProgram( ObjectID ) )
{
LGL3->glGetProgramiv( ObjectID, Target, &SuccessFlag );
LGL3->glGetProgramiv( ObjectID, GL_INFO_LOG_LENGTH, &MaxLength );
着色器程序错误消息的缓冲区在栈上动态分配:
char* Log = ( char* )alloca( MaxLength );
LGL3->glGetProgramInfoLog( ObjectID, MaxLength, &Length, Log );
if ( *Log ) { LOGI( "Program info:\n%s\n", Log ); }
}
else if ( LGL3->glIsShader( ObjectID ) )
{
LGL3->glGetShaderiv( ObjectID, Target, &SuccessFlag );
LGL3->glGetShaderiv( ObjectID, GL_INFO_LOG_LENGTH, &MaxLength );
以类似的方式处理着色器对象:
char* Log = ( char* )alloca( MaxLength );
LGL3->glGetShaderInfoLog( ObjectID, MaxLength, &Length, Log );
if ( *Log ) { LOGI( "Shader info:\n%s\n", Log ); }
}
return SuccessFlag != 0;
}
当顶点和片段着色器对象都成功编译后,将重新链接着色器程序:
bool clGLSLShaderProgram::RelinkShaderProgram()
{
Luint ProgramID = LGL3->glCreateProgram();
FVertexShaderID = AttachSaderID( 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, "Program wasn''t linked" ) )
{
LOGI( "INTERNAL ERROR: Error while shader relinking" );
return false;
}
在这一点上,我们知道着色器程序已成功链接,我们可以将其作为渲染管道的一部分使用。用以下代码替换旧程序:
LGL3->glDeleteProgram( FProgramID );
FProgramID = ProgramID;
从链接的程序中检索活动统一变量的列表并存储它们:
RebindAllUniforms();
将纹理采样器绑定到它们的默认位置。你可以在任何时候添加更多的纹理单元:
LGL3->glUniform1i( LGL3->glGetUniformLocation(FProgramID, "Texture0"), 0);
LGL3->glUniform1i( LGL3->glGetUniformLocation(FProgramID, "Texture1"), 1);
LGL3->glUniform1i( LGL3->glGetUniformLocation(FProgramID, "Texture2"), 2);
LGL3->glUniform1i( LGL3->glGetUniformLocation(FProgramID, "Texture3"), 3);
return true;
}
在RebindAllUniforms()
方法中完成统一变量的排队:
void clGLSLShaderProgram::RebindAllUniforms()
{
Bind();
FUniforms.clear();
Lint ActiveUniforms;
char Buff[256];
LGL3->glGetProgramiv( FProgramID,
GL_ACTIVE_UNIFORMS, &ActiveUniforms );
for ( int i = 0; i != ActiveUniforms; ++i )
{
Lsizei Length;
Lint Size;
Lenum Type;
LGL3->glGetActiveUniform( FProgramID, i,
sizeof( Buff ), &Length, &Size, &Type, Buff );
std::string Name( Buff, Length );
sUniform
对象被构造并推入容器以供将来访问。作为改进,可以排序向量或用std::map
替换以允许更快地访问:
sUniform Uniform( Name );
Uniform.FLocation = LGL3->glGetUniformLocation( FProgramID, Name.c_str() );
FUniforms.push_back( Uniform );
}
}
SetUniform*()
方法组在 GLSL 着色器程序中设置一个命名统一变量的值。这些方法通过调用CreateUniform()
获取统一变量的句柄,然后使用glUniform*()
系列 OpenGL 函数之一来设置新值。字符串名称可以用于着色器的快速原型设计。如果你想要追求性能,可以事先使用CreateUniform()
成员函数获取统一变量的位置,并使用该值与对应的SetUniform*()
调用:
void clGLSLShaderProgram::SetUniformNameFloat( const std::string& Name, const float Float )
{
Lint Loc = CreateUniform( Name );
LGL3->glUniform1f( Loc, Float );
}
void clGLSLShaderProgram::SetUniformNamoatArray( const std::string& Name, int Count, const float& Float )
{
Lint Loc = CreateUniform( Name );
LGL3->glUniform1fv( Loc, Count, &Float );
}
向量被转换为指针。注意以下技巧,ToFloatPtr()
方法返回一个指向向量x
分量的指针。当这个向量被包装到一个向量数组中时,我们也拥有指向数组开头的指针。因此,Count
参数非常有意义,我们可以将向量数组传递给这个方法:
void void clGLSLShaderProgram::SetUniformNameec3Array( const std::string& Name, int Count, const LVector3& Vector )
{
Lint Loc = CreateUniform( Name );
LGL3->glUniform3fv( Loc, Count, Vector.ToFloatPtr() );
}
void clGLSLShaderProgram::SetUniformNameVec4Array( const std::string& Name, int Count, const LVector4& Vector )
{
Lint Loc = CreateUniform( Name );
LGL3->glUniform4fv( Loc, Count, Vector.ToFloatPtr() );
}
矩阵的方法与前面的方法不同,只是参数类型不同:
void clGLSLShaderProgram::SetUniformNameMat4Array( const std::string& Name, int Count, const LMatrix4& Matrix )
{
Lint Loc = CreateUniform( Name );
LGL3->glUniformMatrix4fv( Loc, Count, false,
Matrix.ToFloatPtr() );
}
在SetUniform*()
中使用的CreateUniform()
方法在FUniforms
容器中进行搜索,并返回统一变量的 OpenGL 标识符:
Lint clGLSLShaderProgram::CreateUniform( const std::string& Name )
{
for ( size_t i = 0; i != FUniforms.size(); ++i )
if ( FUniforms[i].FName == Name )
return FUniforms[i].FLocation;
return -1;
}
这个方法用于任何名称都是安全的,因为对于在着色器程序中找不到的统一变量返回的-1
值被 OpenGL 接受并忽略。
Bind()
方法将着色器程序绑定到当前的 OpenGL 渲染上下文:
void clGLSLShaderProgram::Bind()
{
LGL3->glUseProgram( FProgramID );
}
在更复杂的应用程序中,缓存当前绑定的着色器程序的值是有意义的,并且只有当值发生变化时才调用底层 API。
纹理
我们需要包装的最后一个组件是纹理。纹理由clGLTexture
类的实例表示:
class clGLTexture: public iIntrusivounter
{
public:
clGLTexture();
virtual ~clGLTexture();
将纹理绑定到一个指定的 OpenGL 纹理单元:
void Bind( int TextureUnit ) const;
从 API 无关的位图中加载纹理像素:
void LoadFromBitmap( const clPtr<clBitmap>& Bitmap );
设置纹理坐标的钳制模式:
void SetClamping( Lenum Clamping );
处理纹理的数据格式和尺寸:
private:
void SetFormat( Lenum Target, Lenum InternalFormat, Lenum Format, int Width, int Height );
Luint FTexID;
Lenum FInternalFormat;
Lenum FFormat;
};
该实现相当紧凑。下面就是代码:
clGLTexturelGLTexture()
: FTexID( 0 )
, FIntelFormat( 0 )
, FFormat( 0 )
{
}
clGLTexture::~clGLTexture()
{
if ( FTexID ) { LGL3->glDeleteTextures( 1, &FTexID ); }
}
void clGLTexture::Bind( int TextureUnit ) const
{
LGL3->glActiveTexture( GL_TEXTURE0 + TextureUnit );
LGL3->glBindTexture( GL_TEXTURE_2D, FTexID );
}
我们可以设置纹理的格式而不上传任何像素。如果你想要将纹理附加到帧缓冲对象,这非常有用。我们将在第八章,编写渲染引擎中使用这个功能来实现渲染到纹理的功能:
void clGLTexture::SetFormat( Lenum Target, Lenum InternalFormat, Lenum Format, int Width, int Height )
{
if ( FTexID )
{
LGL3->glDeleteTextures( 1, &FTexID );
}
LGL3->glGenTextures( 1, &FTexID );
LGL3->glBindTexture( GL_TEXTURE_2D, FTexID );
LGL3->glTexParameterf( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
LGL3->glTexParameterf( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
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 );
LGL3->glTexImage2D( GL_TEXTURE_2D, 0, InternalFormat, Width, Height, 0, Format, GL_UNSIGNED_BYTE, nullptr );
LGL3->glBindTexture( GL_TEXTURE_2D, 0 );
}
void clGLTexture::SetClamping( Lenum Clamping )
{
Bind( 0 );
按如下方式更新S
和T
的钳制模式:
LGL3->glTexParameteri( GL_TEXTURE_2D,
GL_TEXTURE_WRAP_S, Clamping );
LGL3->glTexParameteri( GL_TEXTURE_2D,
GL_TEXTURE_WRAP_T, Clamping );
}
void clGLTexture::LoadFromBitmap( const clPtr<clBitmap>& Bitmap )
{
if ( !Bitmap ) { return; }
if ( !FTexID )
{
LGL3->glGenTextures( 1, &FTexID );
}
根据位图参数选择合适的 OpenGL 纹理格式:
ChooseInternalFormat( Bitmap->FBitmapParams, &FFormat, &FInternalFormat );
Bind( 0 );
将默认过滤模式设置为GL_LINEAR
以避免构建 mipmap 链:
LGL3->glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
LGL3->glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
int Width = Bitmap->GetWidth();
int Height = Bitmap->GetHeight();
某些 OpenGL ES 实现不允许零尺寸的纹理(是的,我们说的是你,Vivante):
if ( !Width || !Height ) { return; }
将原始位图数据加载到 OpenGL 中:
LGL3->glTexImage2D( GL_TEXTURE_2D, 0, FInternalFormat, Width, Height, 0, FFormat, GL_UNSIGNED_BYTE, Bitmap->FBitmapData );
}
到目前为止,我们已经拥有足够的工具来使用 OpenGL 构建可移植的移动应用程序。本章的示例应用程序1_GLES
在 Windows 和 Android 上渲染了一个彩色的旋转立方体:
Windows 版本可以通过执行>make all -j16 -B
来编译。可以通过调用以下命令来构建 Android 的.apk
包:
>ndk-build -j16 -B
>ant debug
总结
我们学习了如何将原始的 OpenGL 调用封装在一个轻薄的抽象层中,以隐藏 OpenGL ES 3 和 OpenGL 4 之间的许多差异。现在,让我们进入下一章,学习如何使用 OpenGL 和本章展示的类来实现基本的图形用户界面渲染。
第七章:跨*台 UI 与输入系统
在前一章中,我们介绍了用于*台独立渲染的类和接口。在这里,我们在通往 3D OpenGL 渲染器的路上稍作绕行,使用 SDL 库渲染用户界面的元素。为了渲染我们的 UI,我们需要线条、矩形、纹理矩形和文本字符串。
我们将从描述iCanvas
接口开始本章,该接口旨在渲染几何图元。iCanvas
最复杂的部分是 Unicode 文本渲染,它使用 FreeType 库实现。字体字符缓存对于复杂的 UI 来说也是一个非常重要的主题,这里将讨论这个问题。本章的第二部分描述了一个多页图形用户界面,适用于构建多*台应用程序的界面基石。本章以一个 SDL 应用程序结束,该程序展示了我们 UI 系统在实际中的能力。
渲染
目前,我们仅使用 SDL 库,不使用任何 OpenGL,因此我们将声明iCanvas
接口,以允许立即渲染几何图元,但不一定快速,并避免创建前一章描述的GLVertexArray
实例。稍后,我们可能会提供不同的iCanvas
实现,以切换到另一个渲染器:
class iCanvas: public iIntrusiveCounter
{
public:
前两种方法设置当前的渲染颜色,指定为 RGB 整数的三元组或包含额外 alpha 透明度的 4 维向量:
virtual void SetColor( int R, int G, int B ) = 0;
virtual void SetColor( const ivec4& C ) = 0;
Clear()
方法清除屏幕渲染表面:
virtual void Clear() = 0;
Rect()
和Line()
方法分别按照其名称所示渲染矩形和线条:
virtual void Rect( int X, int Y,
int W, int H, bool Filled ) = 0;
virtual void Line( int X1, int Y1, int X2, int Y2 ) = 0;
与纹理相关的一组方法管理纹理的创建和更新。CreateTexture()
方法返回创建的纹理的整数句柄。纹理句柄Idx
作为参数传递给UpdateTexture()
成员函数,以将位图数据上传到纹理中。Pixels
参数持有包含像素数据的位图对象:
virtual int CreateTexture( const clPtr<clBitmap>& Pixels ) = 0;
virtual int UpdateTexture( int Idx, const clPtr<clBitmap>& Pixels ) = 0;
virtual void DeleteTexture( int Idx ) = 0;
TextureRect()
方法使用指定的纹理渲染一个四边形:
virtual void TextureRect( int X, int Y, int W, int H, int SX, int SY, int SW, int SH, int Idx ) = 0;
文本渲染通过单个TextStr()
调用完成,该调用指定了文本应适应(或夹紧)的矩形区域、要渲染的字符串、字体大小的点数、文本颜色以及来自TextRenderer
类的字体 ID,我们将在后面进行描述:
virtual void TextStr( int X1, int Y1, int X2, int Y2, const std::string& Str, int Size, const LVector4i& Color, int FontID );
最后一个公共成员函数是Present()
,它确保所有图元都显示在屏幕上:
virtual void Present() = 0;
};
我们提供了两个iCanvas
接口的实现。一个使用 SDL 库,另一个基于纯 OpenGL 调用。clSDLCanvas
类包含指向 SDL 渲染器对象m_Renderer
的指针。clSDLCanvas
的构造函数接受指向前一章描述的clSDLWindow
类实例的指针,以创建与窗口关联的渲染器:
class clSDLCanvas: public iCanvas
{
private:
SDL_Renderer* m_Renderer;
public:
explicit clSDLCanvas( const clPtr<clSDLWindow>& Window )
{
m_Renderer = SDL_CreateRenderer( Window->GetSDLWindow(), -1, SDL_RENDERER_ACCELERATED );
}
virtual ~clSDLCanvas();
clSDLCanvas
类直接调用相应的 SDL 例程来渲染矩形:
virtual void Rect( int X, int Y, int W, int H, bool Filled ) override
{
SDL_Rect R = { X, Y, W, H };
Filled ?
SDL_RenderFillRect( m_Renderer, &R ) :
SDL_RenderDrawRect( m_Renderer, &R );
}
SetColor()
,Clear()
,和Present()
成员函数也调用相应的 SDL 例程:
virtual void SetColor( int R, int G, int B ) override;
{
SDL_SetRenderDrawColor( m_Renderer, R, G, B, 0xFF );
}
virtual void SetColor( const ivec4& C ) override;
{
SDL_SetRenderDrawColor( m_Renderer, C.x, C.y, C.z, C.w );
}
virtual void Clear() override;
{
SDL_RenderClear( m_Renderer );
}
virtual void Present() override
{
SDL_RenderPresent( m_Renderer );
}
我们必须做一些记录以同步我们的clBitmap
对象与SDL_Texture
。内部结构如下:
std::vector<SDL_Texture*> m_Textures;
CreateTexture()
方法分配一个新的 SDL 纹理:
int CreateTexture( const clPtr<clBitmap>& Pixels )
{
if ( !Pixels ) return -1;
SDL_Texture* Tex = SDL_CreateTexture( m_Renderer,
SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_STREAMING, Pixels->GetWidth(), Pixels->GetHeight() );
SDL_Rect Rect = { 0, 0, Pixels->GetWidth(), Pixels->GetHeight() };
我们将使用Pixels
对象中的像素数据来更新 SDL 纹理:
void* TexturePixels = nullptr;
int Pitch = 0;
int Result = SDL_LockTexture( Tex, &Rect, &TexturePixels, &Pitch );
在这里,我们假设纹理的间距总是等于我们原始像素数据的间距。这在一般情况下不成立。然而,这个假设对于 2 的幂次纹理总是成立的。我们建议你实现尊重间距的纹理更新作为一个练习:
memcpy( TexturePixels, Pixels->FBitmapData, Pitch * Pixels->GetHeight() );
SDL_UnlockTexture(Tex);
创建纹理后,我们将其存储在m_Texture
容器中:
int Idx = (int)m_Textures.size();
m_Textures.push_back( Tex );
return Idx;
}
UpdateTexture()
方法类似,不同之处在于它不创建新纹理,而是重用前一个纹理的大小,因此,更新速度更快:
int UpdateTexture( int Idx, const clPtr<clBitmap>& Pixels )
{
if ( !Pixels ) return;
if ( !Pixels || Idx < 0 || Idx >= (int)m_Textures.size() )
{
return -1;
}
为了更新纹理,我们将调用SDL_LockTexture()
以获取指向纹理数据的指针,并使用memcpy()
来复制位图像素:
Uint32 Fmt;
int Access;
int W, H;
SDL_QueryTexture( m_Textures[Idx], &Fmt, &Access, &W, &H );
SDL_Rect Rect = { 0, 0, W, H };
void* TexturePixels = nullptr;
int Pitch = 0;
int res = SDL_LockTexture( m_Textures[Idx], &Rect, &TexturePixels, &Pitch );
同样,这也只适用于与所提供位图相同间距的纹理:
memcpy( TexturePixels, Pixels->FBitmapData, Pitch * H );
SDL_UnlockTexture( m_Textures[Idx] );
}
当不再需要纹理时,可以使用DeleteTexture()
成员函数来删除它:
void DeleteTexture( int Idx )
{
if ( Idx < 0 || Idx >= (int)m_Textures.size() )
{
return;
}
SDL_DestroyTexture( m_Textures[Idx] );
m_Textures[Idx] = 0;
}
TextureRect()
方法调用SDL_RenderCopy()
函数来绘制纹理映射的矩形:
void TextureRect( int X, int Y, int W, int H,
int SX, int SY, int SW, int SH, int Idx )
{
SDL_Rect DstRect = { X, Y, X + W, Y + H };
SDL_Rect SrcRect = { SX, SY, SX + SW, SY + SH };
SDL_RenderCopy( m_Renderer, m_Textures[Idx], &SrcRect, &DstRect);
}
TextStr()
方法将 UTF-8 编码的字符串渲染到一个矩形区域内。它使用 FreeType 库,需要一些高级机制才能工作。我们将在以下章节讨论其实现。先来看看下面这个:
virtual void TextStr(
int X1, int Y1, int X2, int Y2,
const std::string& Str, int Size,
const LVector4i& Color, int FontID );
};
基本上,iCanvas
接口是围绕 SDL 设计的,其目的是将 SDL 的依赖隐藏在一个轻量级的接口之后,这样相对容易地使用另一种实现。在这里,我们使用 OpenGL 以及前一章中引入的类来实现iCanvas
接口。看看clGLCanvas
类。
首先,我们需要定义一些 GLSL 着色器,以渲染填充和纹理矩形。我们可以自然地使用 C++11 原始字符串字面量来做这件事。顶点着色器重新映射我们画布中使用的窗口标准化坐标到 OpenGL 标准化设备坐标,并且被所有片段程序共享:
static const char RectvShaderStr[] = R"(
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;
我们取 0,0…1,1 的矩形并将其重新映射到所需的矩形X1,Y1-X2,Y2。这样,我们可以使用单个顶点数组对象来渲染任何矩形:
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;
}
)";
这个片段着色器用于渲染一个单色矩形:
static const char RectfShaderStr[] = R"(
uniform vec4 u_Color;
out vec4 out_FragColor;
in vec2 Coords;
void main()
{
out_FragColor = u_Color;
}
)";
纹理映射的版本稍微复杂一些。我们将常数颜色与纹理进行调制:
static const char TexRectfShaderStr[] = R"(
uniform vec4 u_Color;
out vec4 out_FragColor;
in vec2 Coords;
uniform sampler2D Texture0;
void main()
{
out_FragColor = u_Color * texture( Texture0, Coords );
}
)";
在clGLCanvas
的构造函数中,我们将创建渲染所需的所有持久 OpenGL 对象:
clGLCanvas::clGLCanvas( const clPtr<clSDLWindow>& Window )
: m_Window( Window )
{
初始化我们的 OpenGL 包装器:
LGL3 = std::unique_ptr<sLGLAPI>( new sLGLAPI() );
LGL::GetAPI( LGL3.get() );
这个矩形的几何形状被重用来渲染任何尺寸的矩形:
m_Rect = clGeomServ::CreateRect2D( 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, false, 1 );
m_RectVA = new clGLVertexArray();
m_RectVA->SetVertexAttribs( m_Rect );
从源代码中链接两个着色器程序:
m_RectSP = new clGLSLShaderProgram( RectvShaderStr, RectfShaderStr );
m_TexRectSP = new clGLSLShaderProgram( RectvShaderStr, TexRectfShaderStr );
}
一个私有辅助函数用于将整数窗口坐标转换为我们在着色器中使用的标准化窗口坐标:
vec4 clGLCanvas::ConvertScreenToNDC( int X, int Y, int W, int H ) const
{
float WinW = static_cast<float>( m_Window->GetWidth() );
float WinH = static_cast<float>( m_Window->GetHeight() );
vec4 Pos( static_cast<float>( X ) / WinW,
static_cast<float>( Y ) / WinH,
static_cast<float>( X + W ) / WinW,
static_cast<float>( Y + H ) / WinH );
return Pos;
}
现在,实际的渲染代码非常直接。首先让我们渲染一个填充的矩形:
void clGLCanvas::Rect( int X, int Y, int W, int H, bool Filled )
{
vec4 Pos = ConvertScreenToNDC( X, Y, W, H );
LGL3->glDisable( GL_DEPTH_TEST );
m_RectSP->Bind();
m_RectSP->SetUniformNameVec4Array( "u_Color", 1, m_Color );
m_RectSP->SetUniformNameVec4Array( "u_RectSize", 1, Pos );
由于 alpha 混合是一个非常耗时的操作,只有当颜色的 alpha 通道实际暗示透明时才启用它:
if ( m_Color.w < 1.0f )
{
LGL3->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
LGL3->glEnable( GL_BLEND );
}
m_RectVA->Draw( false );
再次禁用混合:
if ( m_Color.w < 1.0f )
{
LGL3->glDisable( GL_BLEND );
}
}
我们的实现非常简单,并不进行任何状态更改跟踪,一旦你进行大量Rect()
调用时,这会非常耗时。我们建议你向iCanvas
接口添加一个方法,该方法可以一次渲染一系列矩形,并在渲染之前将它们分类为透明和非透明桶。这样,多个矩形的渲染速度可以相对较快。顺便一提,SDL 以类似的方式提供SDL_FillRects()
函数。
由于我们可以使用我们的clGLTexture
类,纹理管理功能现在变得简单了:
int clGLCanvas::CreateTexture( const clPtr<clBitmap>& Pixels )
{
if ( !Pixels ) return -1;
m_Textures.emplace_back( new clGLTexture() );
m_Textures.back()->LoadFromBitmap( Pixels );
return m_Textures.size()-1;
}
UpdateTexture()
和DeleteTextures()
函数几乎是一行代码,除了参数有效性检查:
void clGLCanvas::UpdateTexture( int Idx, const clPtr<clBitmap>& Pixels )
{
if ( m_Textures[ Idx ] ) m_Textures[ Idx ]->LoadFromBitmap( Pixels );
}
void clGLCanvas::DeleteTexture( int Idx )
{
m_Textures[ Idx ] = nullptr;
}
让我们使用这些纹理绘制一个纹理矩形。大部分工作与Rect()
类似,除了纹理绑定:
void clGLCanvas::TextureRect( int X, int Y, int W, int H, int SX, int SY, int SW, int SH, int Idx )
{
if ( Idx < 0 || Idx >= (int)m_Textures.size() )
{
return;
}
vec4 Pos = ConvertScreenToNDC( X, Y, W, H );
LGL3->glDisable( GL_DEPTH_TEST );
将所需的纹理绑定到纹理单元0
:
m_Textures[ Idx ]->Bind( 0 );
使用m_TexRectSP
着色器程序:
m_TexRectSP->Bind();
m_TexRectSP->SetUniformNameVec4Array( "u_Color", 1, m_Color );
m_TexRectSP->SetUniformNameVec4Array( "u_RectSize", 1, Pos );
对于带有透明 texel 的纹理矩形,总是使用混合:
LGL3->glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
LGL3->glEnable( GL_BLEND );
m_RectVA->Draw( false );
LGL3->glDisable( GL_BLEND );
}
在 OpenGL 状态更改上可以实现类似的优化。我们留给你实现这种缓存机制。现在,让我们继续文本渲染,以便稍后可以返回到clGLCanvas::TextStr()
。
文本渲染
在本节中,我们将描述clTextRenderer
类中实现的文本渲染过程的所有重要细节。以下是我们的文本渲染器的部分:
-
UTF-8 字符串解码(
en.wikipedia.org/wiki/UTF-8
) -
文本大小计算、字距调整和前进计算
-
单个字形的渲染,就像第二章中的 FreeType 示例,本地库
-
字体和字形加载与缓存
-
字符串渲染
我们假设所有字符串都是 UTF-8 编码,因为这样所有 ASCII 码在 0 到 127 之间的拉丁字符正好占一个字节,各种国家符号最多占四个字节。UTF-8 唯一的问题是 FreeType 接受固定宽度的 2 字节 UCS-2 编码,因此我们必须包含解码例程以从 UTF-8 转换为 UCS-2。
注意
有一个关于每个软件开发者都必须了解的 Unicode 和字符集的绝对基础知识的优秀文章。查看www.joelonsoftware.com/articles/Unicode.html
。
我们将渲染字符串的每个字符存储在clTextRenderer
的FString
字段中:
class clTextRenderer
{
std::vector<sFTChar> FString;
每个字符的描述存储在以下结构中,其中FChar
字段包含 UCS-2 字符代码,内部字符索引为FIndex
:
struct sFTChar
{
FT_UInt FChar;
FT_UInt FIndex;
FGlyph
字段保存了带有渲染字形的 FreeType FT_Glyph
结构:
FT_Glyph FGlyph;
解码字符编码后,我们计算每个字形的像素宽度和前进值,并将这些值存储在FWidth
和FAdvance
中:
FT_F26Dot6 FWidth;
FT_F26Dot6 FAdvance;
FCacheNode
字段由 FreeType 字体缓存子系统内部使用,以下是其简要描述:
FTC_Node FCacheNode;
默认构造函数为每个字段设置空值:
sFTChar()
: FChar( 0 ), FIndex( ( FT_UInt )( -1 ) )
, FGlyph( nullptr ), FAdvance( 0 )
, FWidth( 0 ), FCacheNode( nullptr )
{ }
};
现在我们有一个结构来保存我们的字符,下面将展示如何处理字符串并计算每个字符的位置。本章的后续段落描述了clTextRenderer
的内部细节,因此当我们声明新字段时,意味着它们属于clTextRenderer
类。我们从可以渲染字符串的高级例程开始。之后,我们讨论 UTF-8 解码,最后展示如何实现字体管理和缓存。
计算字形位置和字符串大小的像素值
LoadStringWithFont()
成员函数接收一个文本字符串、内部字体标识符和所需的字体高度(以像素为单位)。它计算FString
数组中每个元素的参数。此例程用于渲染和文本大小计算:
bool TextRenderer::LoadStringWithFont( const std::string& S, int ID, int Height )
{
if ( ID < 0 ) { return false; }
首先,我们获取字体句柄并确定是否需要字距调整。FFace
是clTextRenderer
中的FT_Face
类型的字段。GetSizedFace()
方法检索与所需高度匹配的字体。它使用内部字体缓存,以避免为单个分辨率多次渲染游戏字形,这一部分将在本章后面详细讨论。请看以下代码:
FFace = GetSizedFace( ID, Height );
if ( !FFace ) { return false; }
bool UseKerning = FT_HAS_KERNING( FFace );
然后,我们将 UTF-8 字符串解码为 UCS-2,并填充FString
数组:
DecodeUTF8( S.c_str() );
填充FString
后,我们渲染每个字符并计算位置:
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;
当我们知道字符的索引时,我们可以调用FT_RenderGlyph()
方法,但每次遇到时都渲染单个字形是相当低效的。GetGlyph()
例程负责从缓存中提取字形:
Char.FGlyph = ( Char.FIndex != -1 ) ?
GetGlyph( ID, Height, ch, FT_LOAD_RENDER, &Char.FCacheNode ) : nullptr;
如果字形加载成功,我们会调用SetAdvance()
方法:
if ( !Char.FGlyph || Char.FIndex == -1 ) continue;
SetAdvance( Char );
可选地,我们可以调用Kern()
方法来调整当前字符的前进值:
if ( i > 0 && UseKerning )
{
Kern( FString[i - 1], Char );
}
}
return true;
}
辅助SetAdvance()
方法计算字形的边界框,并将其宽度和前进值存储在sFTChar
结构中:
void TextRenderer::SetAdvance( sFTChar& Char )
{
Char.FAdvance = Char.FWidth = 0;
if ( !Char.FGlyph ) return;
前进值以22:10
固定点值存储,我们使用位运算移位将其转换为整数值:
Char.FAdvance = Char.FGlyph->advance.x >> 10;
FT_Glyph_Get_CBox()
函数返回一个边界框;我们使用它的xMax
字段:
FT_BBox bbox;
FT_Glyph_Get_CBoxPTR( Char.FGlyph,
FT_GLYPH_BBOX_GRIDFIT, &bbox );
Char.FWidth = bbox.xMax;
对于某些字形,如空格,宽度为零,我们使用FAdvance
字段:
if ( Char.FWidth == 0 && Char.FAdvance != 0 )
{
Char.FWidth = Char.FAdvance;
}
}
Kern()
例程取两个相邻字符并计算前进校正。我们的文本渲染器不支持自动连字替换,如果需要这种替换,这里可能是执行它的地方:
void TextRenderer::Kern( sFTChar& Left, const sFTChar& Right )
{
字符串的开头和结尾不需要进行字距调整:
if ( Left.FIndex == -1 || Right.FIndex == -1 ) return;
FT_Vector Delta;
FT_GetKerning()
调用计算当前字符的相对偏移校正:
FT_Get_KerningPTR( FFace, Left.FIndex, Right.FIndex, FT_KERNING_DEFAULT, &Delta );
结果被加到前进值中:
Left.FAdvance += Delta.x;
}
使用FString
数组,我们可以通过简单相加各个字符的大小来轻松计算渲染字符串的大小。稍后,这个大小值用于为字符串分配输出位图:
void TextRenderer::CalculateLineParameters( int* Width, int* MinY, int* MaxY, int* BaseLine ) const
{
变量StrMinY
和StrMaxY
保存了字符串中字符的最小和最大像素坐标:
int StrMinY = -1000, StrMaxY = -1000;
if ( FString.empty() ) StrMinY = StrMaxY = 0;
SizeX
变量保存字符串位图中的水*像素数。我们迭代FString
数组,并将每个字符的前进值加到SizeX
上:
int SizeX = 0;
for ( size_t i = 0 ; i != FString.size(); i++ )
{
if ( FString[i].FGlyph == nullptr ) continue;
对于每个字符,我们获取字形的位图并更新SizeX
变量:
FT_BitmapGlyph BmpGlyph = ( FT_BitmapGlyph )FString[i].FGlyph;
SizeX += FString[i].FAdvance;
int Y = BmpGlyph->top;
int H = BmpGlyph->bitmap.rows;
读取字形尺寸后,我们更新字符串的最小和最大尺寸:
if ( Y > StrMinY ) StrMinY = Y;
if ( H - Y > StrMaxY ) StrMaxY = H - Y;
}
最后,我们通过将26:6
固定点值SizeX
转换为像素来计算字符串的整数值Width
:
if ( Width ) { *Width = ( SizeX >> 6 ); }
if ( BaseLine ) { *BaseLine = StrMaxY; }
if ( MinY ) { *MinY = StrMinY; }
if ( MaxY ) { *MaxY = StrMaxY; }
}
在进行字形渲染之前,我们还需要检查另一个重要事项。让我们概述 UTF-8 字符解码的过程。
解码 UTF-8
前一节提到的DecodeUTF8()
例程,在LoadStringWithFont()
中使用,迭代传入的字节数组,并使用DecodeNextUTF8Char()
获取 UCS-2 编码中的字符代码:
bool TextRenderer::DecodeUTF8( const char* InStr )
{
首先,我们存储一个指向缓冲区的指针,并将当前位置设置为 0:
FIndex = 0;
FBuffer = InStr;
FLength
字段包含InStr
中的字节数。DecodeNextUTF8Char()
方法使用FLength
在到达字符串末尾时停止解码过程:
FLength = ( int )strlen( InStr );
FString.clear();
int R = DecodeNextUTF8Char();
然后,我们将遍历FBuffer
中的字节数组,直到遇到零字节:
while ( ( R != UTF8_LINE_END ) && ( R != UTF8_DECODE_ERROR ) )
{
sFTChar Ch;
UCS-2 字符代码是我们在新的sFTChar
实例中唯一更改的东西:
Ch.FChar = R;
FString.push_back( Ch );
R = DecodeNextUTF8Char();
}
return ( R != UTF8_DECODE_ERROR );
}
DecodeNextUTF8Char()
中的 UTF-8 解码器基于来自www.json.org/JSON_checker/utf8_decode.c
的 JSON 检查器的源代码。为了节省空间,我们省略了相当直接的位操作。低级实现细节可以在伴随的源代码中找到,只需查看TextRenderer.h
和TextRenderer.cpp
。
字形渲染
RenderLineOnBitmap()
方法接收一个分配好的位图作为输出表面,并使用指定的字体标识符渲染给定的文本字符串。LeftToRight
参数告诉我们文本是从左到右还是从右到左书写:
void TextRenderer:
{
LoadStringWithFont( TextString, FontID, FontHeight );
加载后,通过再次迭代FString
容器,并为每个字符调用DrawGlyphOnBitmap()
方法来完成文本渲染:
int x = StartX << 6;
for ( size_t j = 0 ; j != FString.size(); j++ )
{
if ( FString[j].FGlyph != 0 )
{
FT_BitmapGlyph BmpGlyph = ( FT_BitmapGlyph ) FString[j].FGlyph;
我们通过累加每个字符的前进值来跟踪当前的水*位置变量x
。对于每个非空字形,我们计算一个实际的屏幕位置,考虑到由LeftToRight
参数指定的实际文本方向:
int in_x = ( x >> 6 ) +
( LeftToRight ? 1 : -1 ) * BmpGlyph->left;
如果方向是从右到左,我们将相应地校正位置:
if ( !LeftToRight )
{
in_x += BmpGlyph->bitmap.width;
in_x = StartX + ( StartX - in_x );
}
DrawGlyphOnBitmap( Out, &BmpGlyph->bitmap,
in_x, Y - BmpGlyph->top, Color );
}
在每次迭代的末尾,我们使用前进值来移动水*计数器:
x += FString[j].FAdvance;
}
}
包装例程RenderTextWithFont()
预先计算输出位图的大小,并返回一个可直接使用的图像:
clPtr<clBitmap> TextRenderer::RenderTextWithFont( const std::string& TextString, int FontID, int FontHeight, const ivec4& Color, bool LeftToRight )
{
if ( !LoadStringWithFont( TextString, FontID, FontHeight ) )
{ return nullptr; }
int W, Y;
int MinY, MaxY;
CalculateLineParameters( &W, &MinY, &MaxY, &Y );
int H2 = MaxY + MinY;
在计算了文本大小之后,我们会分配一个输出位图,清除它,并调用RenderLineOnBitmap()
方法:
clPtr<clBitmap> Result = make_intrusive<clBitmap>( W, H2, L_BITMAP_BGRA8 );
Result->Clear();
RenderLineOnBitmap()
调用为从右到左的文本固定了起始位置:
RenderLineOnBitmap( TextString, FontID, FontHeight, LeftToRight ? 0 : W - 1, MinY, Color, LeftToRight, Result );
return Result;
}
DrawGlyphOnBitmap()
方法与我们在第二章本地库中使用的代码类似。我们遍历字形的位图中的所有像素,并使用 FreeType 返回的数据设置它们:
void TextRenderer::DrawGlyphOnBitmap( const clPtr<clBitmap>& Out, FT_Bitmap* Bitmap, int X0, int Y0, const ivec4& Color ) const
{
int W = Out->GetWidth();
int Width = W - X0;
if ( Width > Bitmap->width ) { Width = Bitmap->width; }
for ( int Y = Y0 ; Y < Y0 + Bitmap->rows ; ++Y )
{
unsigned char* Src = Bitmap->buffer +
( Y - Y0 ) * Bitmap->pitch;
在掩模创建模式下,我们可以直接将字形复制到输出位图中,忽略Color
参数,即只渲染灰度掩模:
if ( FMaskMode )
{
for ( int X = X0 + 0 ; X < X0 + Width ; X++ )
{
int Int = *Src++;
int OutMaskCol = ( Int & 0xFF );
Out->SetPixel(X, Y,
ivec4i(OutMaskCol,
OutMaskCol, OutMaskCol, 255) );
}
}
else
对于彩色渲染,我们会获取源像素,并根据掩模将其与指定颜色混合:
{
for ( int X = X0 + 0 ; X < X0 + Width ; X++ )
{
unsigned int Int = *Src++;
ivec4 Col = BlendColors(Color, Out->GetPixel(X, Y), (Int & 0xFF));
if ( Int > 0 )
{
Col.w = Int;
Out->SetPixel(X, Y, Col);
}
}
}
}
}
BlendColors()
例程在颜色C1
和C2
之间执行线性插值。这里的右移位代替了除以 256。为了避免浮点运算和转换,混合因子从 0 变化到 255,因此在公式中使用值 255 而不是1.0f
:
inline LVector4i BlendColors( const LVector4i& C1, const LVector4i& C2, unsigned int F )
{
int r = ((C1.x) * F >> 8) + ((C2.x) * (255 - F) >> 8);
int g = ((C1.y) * F >> 8) + ((C2.y) * (255 - F) >> 8);
int b = ((C1.z) * F >> 8) + ((C2.z) * (255 - F) >> 8);
return LVector4i(r, g, b, 255);
}
现在,我们知道如何渲染字形。让我们找出如何加载、管理和缓存不同的字体。
字体初始化和缓存
到目前为止,我们还没有描述字体管理、字形渲染和重用字符位图的细节。
首先,我们将声明一个 FreeType 库句柄,供每次调用 FreeType API 时使用:
FT_Library FLibrary;
对于我们使用的每种字体,都需要一个渲染字形缓存和一个字符映射缓存。这些缓存由一个FTC_Manager
实例维护:
FTC_Manager FManager;
接下来,我们需要字形和字符映射缓存:
FTC_ImageCache FImageCache;
FTC_CMapCache FCMapCache;
我们在FAllocatedFonts
字段中跟踪已加载字体文件的字节缓冲区。std::map
的键是字体文件的名称:
std::map<std::string, void*> FAllocatedFonts;
FFontFaceHandles
映射是另一个初始化的 FreeType 字体句柄的容器:
std::map<std::string, FT_Face> FFontFaceHandles;
私有的LoadFontFile()
方法使用我们的虚拟文件系统机制读取字体文件,并将初始化的字体添加到前面代码中声明的容器中:
FT_Error clTextRenderer::LoadFontFile( const std::string& FileName )
{
if ( !FInitialized ) { return -1; }
我们防止已加载字体的重新加载:
if ( FAllocatedFonts.count( FileName ) > 0 ) { return 0; }
新字体被读取到clBlob
对象中,并将其数据复制到一个单独的Data
缓冲区:
clPtr<clBlob> DataBlob = LoadFileAsBlob(g_FS, FileName);
int DataSize = DataBlob->GetSize();
char* Data = new char[DataSize];
memcpy( Data, DataBlob->GetData(), DataSize );
FT_New_Memory_Face()
函数用于创建一个新的FT_Face
对象,并将其存储在FFontFaceHandles
数组中:
FT_Face TheFace;
FT_Error Result = FT_New_Memory_FacePTR( FLibrary, ( FT_Byte* )Data, ( FT_Long )DataSize, 0, &TheFace );
if ( Result == 0 )
{
FFontFaceHandles[ FileName ] = TheFace;
Data
缓冲区被添加到FAllocatedFonts
中,字体名称被添加到FFontFaces
容器中:
FAllocatedFonts[ FileName ] = ( void* )Data;
FFontFaces.push_back( FileName );
}
return Result;
}
我们正在开发的clTextRenderer
类在InitFreeType()
方法中包含了初始化代码:
void clTextRenderer::InitFreeType()
{
这里我们省略了LoadFT()
方法的描述,因为在 Windows 上,它只是简单地加载一个 FreeType 动态库文件并解析函数指针。对于 Android,此方法为空,并返回true
:
FInitialized = LoadFT();
if ( FInitialized )
{
FInitialized = false;
实际的初始化代码创建了一个 FreeType 库实例并分配了缓存:
if ( FT_Init_FreeTypePTR( &FLibrary ) != 0 ) { return; }
在 FreeType 之后初始化缓存管理器。FreeType_Face_Requester
是一个指向我们下面代码中描述的方法的函数指针。它解析字体文件名并实际加载字体数据:
if ( FTC_Manager_NewPTR( FLibrary, 0, 0, 0, FreeType_Face_Requester, this, &FManager ) != 0 )
{ return; }
最后,初始化两个缓存,类似于管理器:
if ( FTC_ImageCache_NewPTR( FManager,
&FImageCache ) != 0)
{
return;
}
if ( FTC_CMapCache_NewPTR( FManager, &FCMapCache ) != 0 )
{
return;
}
FInitialized = true;
}
}
FreeType 的逆序完成初始化:
void TextRenderer::StopFreeType()
{
首先,我们通过调用FreeString
来清除FString
容器:
FreeString();
然后,我们将释放FAllocatedFonts
映射中包含字体数据的内存块:
for ( auto p = FAllocatedFonts.begin();
p != FAllocatedFonts.end() ; p++ )
{
delete[] ( char* )( p->second );
}
最后,我们清除字体面容器,并销毁缓存管理器和库实例:
FFontFaces.clear();
if ( FManager ) { FTC_Manager_DonePTR( FManager ); }
if ( FLibrary ) { FT_Done_FreeTypePTR( FLibrary ); }
}
FreeString
方法为FString
向量的每个元素销毁缓存的字形:
void TextRenderer::FreeString()
{
for ( size_t i = 0 ; i < FString.size() ; i++ )
if ( FString[i].FCacheNode != nullptr )
FTC_Node_UnrefPTR( FString[i].FCacheNode,
FManager );
FString.clear();
}
当 FreeType 发现缓存中没有所需的字体时,它会调用我们的FreeType_Face_Requester()
回调来初始化新的字体面:
FT_Error TextRenderer::FreeType_Face_Requester(
FTC_FaceID FaceID,
FT_Library Library,
FT_Pointer RequestData,
FT_Face* TheFace )
{
这是一个真正需要将 C 风格字体指针转换为整型标识符的尴尬地方。我们使用低 32 位作为标识符:
#if defined(_WIN64) || defined(__x86_64__)
long long int Idx = ( long long int )FaceID;
int FaceIdx = ( int )( Idx & 0x7FFFFFFFF );
#else
int FaceIdx = reinterpret_cast< int >( FaceID );
#endif
如果FaceIdx
小于零,它是一个有效指针,并且字体已经被加载:
if ( FaceIdx < 0 ) { return 1; }
我们正在描述的方法是 C 语言库的回调,因此我们使用RequestData
模拟this
指针。在InitFreeType()
方法中,我们提供了this
作为参数给FTC_Manager_New
:
clTextRenderer* This = ( clTextRenderer* )RequestData;
我们从FFontFaces
数组中提取文件名:
std::string FileName = This->FFontFaces[FaceIdx];
调用LoadFontFile()
可能会返回零,如果我们已经加载了文件:
FT_Error LoadResult = This->LoadFontFile( FileName );
如果我们还没有加载文件,我们会在FFontFaceHandles
数组中查找字体:
*TheFace = ( LoadResult == 0 ) ?
This->FFontFaceHandles[FileName] : nullptr;
return LoadResult;
}
我们正在接*clTextRenderer
的完整视图,只剩下与字体和字形相关的少数几个方法。第一个是GetSizedFace()
,我们在LoadStringWithFont()
中使用过它:
FT_Face clTextRenderer::GetSizedFace( int FontID, int Height )
{
要开始在给定字体高度渲染字形,我们填充FTC_ScalerRec
结构以设置渲染参数。IntToID()
例程将整数标识符转换为 void 指针,与FreeType_Face_Requester()
中的代码相反:
FTC_ScalerRec Scaler;
Scaler.face_id = IntToID( FontID );
Scaler.height = Height;
Scaler.width = 0;
Scaler.pixel = 1;
FT_Size SizedFont;
FTC_Manager_LookupSize()
函数在缓存中查找FT_Size
结构,我们将其提供给FT_ActivateSize()
。在此之后,我们的字形以Height
参数等于的大小进行渲染:
if ( FTC_Manager_LookupSizePTR( FManager, &Scaler,
&SizedFont ) != 0 ) return nullptr;
if ( FT_Activate_SizePTR( SizedFont ) != 0 ) return nullptr;
return SizedFont->face;
}
第二个辅助方法是GetGlyph()
,它渲染单个字形:
FT_Glyph TextRenderer::GetGlyph( int FontID, int Height, FT_UInt Char, FT_UInt LoadFlags, FTC_Node* CNode )
{
在这里,我们将 UCS-2 代码转换为字符索引:
FT_UInt Index = GetCharIndex( FontID, Char );
ImageType
结构被填充了字形渲染参数:
FTC_ImageTypeRec ImageType;
ImageType.face_id = IntToID( FontID );
ImageType.height = Height;
ImageType.width = 0;
ImageType.flags = LoadFlags;
然后,FTC_ImageCache_Lookup()
函数查找先前渲染的字形,如果尚未渲染,则渲染一个:
FT_Glyph Glyph;
if ( FTC_ImageCache_LookupPTR( FImageCache,
&ImageType, Index, &Glyph, CNode ) != 0 )
{ return nullptr; }
return Glyph;
}
第三个方法GetCharIndex()
使用 FreeType 字符映射缓存快速将 UCS-2 字符代码转换为字形索引:
FT_UInt clTextRenderer::GetCharIndex( int FontID, FT_UInt Char )
{
return FTC_CMapCache_LookupPTR( FCMapCache,
IntToID( FontID ), -1, Char );
}
IntToID()
例程与FreeType_Face_Requester()
中的强制转换代码类似。它所做的就是将整数字体面标识符转换为 C void 指针:
inline void* IntToID( int FontID )
{
#if defined(_WIN64) || defined (__x86_64__)
long long int Idx = FontID;
#else
int Idx = FontID;
#endif
FTC_FaceID ID = reinterpret_cast<void*>( Idx );
return ID;
}
最后,我们需要GetFontHandle()
方法,它加载字体文件并返回新的有效字体面标识符:
int clTextRenderer::GetFontHandle( const std::string& FileName )
{
首先,我们将尝试加载文件。如果文件已经加载,可能会返回零:
if ( LoadFontFile( FileName ) != 0 )
return -1;
我们在 FFontFaces 容器中查找此字体并返回其索引:
for ( int i = 0 ; i != ( int )FFontFaces.size() ; i++ ) { }
if ( FFontFaces[i] == FileName )
return i;
return -1;
}
我们拥有在位图上渲染 Unicode 字符所需的所有组件。让我们看看如何使用这个功能来扩展clCanvas
的文本渲染能力。
将文本渲染器集成到画布中
现在我们有了clTextRenderer
类,我们可以实现clGLCanvas::TextStr()
:
void clGLCanvas::TextStr( int X1, int Y1, int X2, int Y2, const std::string& Str, int Size, const ivec4& Color, int FontID )
{
首先,我们将字符串渲染成位图:
auto B = TextRenderer::Instance()->RenderTextWithFont( Str, FontID, Size, Color, true );
静态纹理在所有对TextStr()
的调用之间共享。虽然性能不是特别高,也不是多线程的,但是非常简单:
static int Texture = this->CreateTexture();
然后,我们从这个位图中更新静态纹理:
UpdateTexture( Texture, B );
计算完输出大小后,我们将调用TextureRect()
方法,使用我们的文本字符串渲染位图:
int SW = X2 - X1 + 1, SH = Y2 - Y1 + 1;
this->TextureRect( X1, Y1, X2 - X1 + 1, Y2 - Y1 + 1, 0, 0, SW, SH, Texture );
}
使用单例模式实现全局访问clTextRenderer
的单个实例:
clTextRenderer* clTextRenderer::Instance()
{
static clTextRenderer Instance;
return &Instance;
}
我们现在可以使用iCanvas
接口来渲染文本。让我们绘制一个图形用户界面,我们可以在其中放置文本。
组织 UI 系统
创建了立即模式渲染的iCanvas
接口后,我们可以转向用户界面实现。为了创建有意义的应用程序,仅能渲染静态甚至动画图形信息并不总是足够的。应用程序必须对用户输入做出反应,对于移动设备来说,这通常意味着响应触摸屏事件。在这里,我们创建了一个由三种基本元素(称为视图)组成的简约图形用户界面:
-
clUIView
:这是一个逻辑容器,也是其他视图的基类 -
clUIStatic
:这是一个带有文本的静态标签 -
clUIButton
:这是一个一旦被触摸就会触发事件的物体
每个视图都是一个矩形区域,能够渲染自身并对外部事件(如定时和用户触摸)做出反应。由于我们在使用 NDK,同时我们也想在桌面机上调试我们的软件,因此我们必须将特定于操作系统的队列中的事件重定向到 C++事件处理代码。
基础的 UI 视图
我们为每个 UI 元素定义了clUIView
接口。这个接口包括 UI 视图的几何属性:
class clUIView: public iIntrusiveCounter
{
protected:
这个类包含了 UI 元素的几何属性。m_X
和m_Y
字段包含在父坐标框架中的相对坐标。m_ScreenX
和m_ScreenY
字段包含在屏幕参考框架中的绝对坐标。m_Width
和m_Height
字段分别存储元素的宽度和高度:
int m_X, m_Y;
int m_ScreenX, m_ScreenY;
int m_Width, m_Height;
类的私有部分包含子视图布局的标志和设置。这些设置稍后会在LayoutChildViews()
方法中使用。m_ParentFractionX
和m_ParentFractionY
值用于覆盖作为父视图大小的百分比的m_Width
和m_Height
。如果这些值大于 1,它们将被忽略。它们在LayoutChildViews
中的显式使用如下所示。m_AlignV
和m_AlignH
包含坐标的不同对齐模式:
private:
float m_ParentFractionX, m_ParentFractionY;
eAlignV m_AlignV;
eAlignH m_AlignH;
int m_FillMode;
最后一个字段是m_ChildViews
向量,其中包含指向子视图的指针,顾名思义:
std::vector< clPtr<clUIView> > m_ChildViews;
默认构造函数为每个字段设置初始值:
public:
clUIView():
m_X( 0 ), m_Y( 0 ), m_Width( 0 ), m_Height( 0 ),
m_ScreenX( 0 ), m_ScreenY( 0 ), m_ParentFractionX( 1.0f ),
m_ParentFractionY( 1.0f ), m_AlignV( eAlignV_DontCare ),
m_AlignH( eAlignH_DontCare ), m_ChildViews( 0 )
{}
类接口包含访问属性的Get*
和Set*
单行函数
virtual void SetPosition( int X, int Y ) { m_X = X; m_Y = Y; }
virtual void SetSize( int W, int H )
{ m_Width = W; m_Height = H; }
virtual void SetWidth( int W ) { m_Width = W; }
virtual void SetHeight( int H ) { m_Height = H; }
virtual int GetWidth() const { return m_Width; }
virtual int GetHeight() const { return m_Height; }
virtual int GetX() const { return m_X; }
virtual int GetY() const { return m_Y; }
然后,是布局参数的获取器和设置器:
virtual void SetAlignmentV( eAlignV V ) { m_AlignV = V; }
virtual void SetAlignmentH( eAlignH H ) { m_AlignH = H; }
virtual eAlignV GetAlignmentV() const { return m_AlignV; }
virtual eAlignH GetAlignmentH() const { return m_AlignH; }
virtual void SetParentFractionX( float X )
{ m_ParentFractionX = X; }
virtual void SetParentFractionY( float Y )
{ m_ParentFractionY = Y; }
Add()
和Remove()
方法提供了对m_ChildViews
容器的访问:
virtual void Add( const clPtr<clUIView>& V )
{
m_ChildViews.push_back( V );
}
virtual void Remove( const clPtr<clUIView>& V )
{
m_ChildViews.erase( std::remove( m_ChildViews.begin(), m_ChildViews.end(), V ), m_ChildViews.end() );
}
GetChildViews()
方法直接提供了对m_ChildViews
的只读访问:
virtual const std::vector< clPtr<clUIView> >&
GetChildViews() const { return m_ChildViews; }
Draw()
方法调用PreDrawView()
来渲染该 UI 元素的背景层,然后它为每个子视图调用Draw()
,最后,调用PostDrawView()
完成该 UI 元素的渲染过程:
virtual void Draw( const clPtr<iCanvas>& C )
{
this->PreDrawView( C );
for ( auto& i : m_ChildViews )
{
i->Draw( C );
}
this->PostDrawView( C );
}
UpdateScreenPositions()
方法重新计算子视图的绝对屏幕位置:
virtual void UpdateScreenPositions( int ParentX = 0, int ParentY = 0 )
{
m_ScreenX = ParentX + m_X;
m_ScreenY = ParentY + m_Y;
for ( auto& i : m_ChildViews )
{
i->UpdateScreenPositions( m_ScreenX, m_ScreenY );
}
}
事件处理部分包括Update()
和OnTouch()
方法。Update()
方法通知所有子视图已经过了一段时间:
virtual void Update( double Delta )
{
for( auto& i: m_ChildViews )
i->Update( Delta );
}
OnTouch()
方法接受屏幕坐标和触摸标志:
virtual bool OnTouch( int x, int y, bool Pressed )
{
if ( IsPointOver( x, y ) )
{
检查触摸事件是否被任何子视图处理:
for( auto& i: m_ChildViews )
{
if( i->OnTouch( x, y, Pressed ) )
return true;
}
}
return false;
}
IsPointOver()
方法检查点是否在视图内:
virtual bool IsPointOver( int x, int y ) const
{
return ( x >= m_ScreenX ) &&
( x <= m_ScreenX + m_Width ) &&
( y >= m_ScreenY ) &&
( y <= m_ScreenY + m_Height );
}
受保护的部分包含两个虚拟方法,用于渲染实际clUIView
的内容。PreDrawView()
方法在渲染子视图之前调用,因此此调用的可见结果可能会被子视图擦除,例如背景层。PostDrawView()
方法在所有子视图渲染后调用,就像渲染图像顶部的装饰:
protected:
virtual void PreDrawView( const clPtr<iCanvas>& C ) {};
virtual void PostDrawView( const clPtr<iCanvas>& C ) {};
};
这个机制使得 UI 渲染和自定义成为可能。在我们 UI 可以生动呈现之前,我们还需要一个事件分派机制。让我们来实现它。
事件
在最低级别,所有来自 Android 或桌面操作系统的的事件都由 SDL 库处理,我们只需编写这些事件的处理程序:
bool clSDLWindow::HandleEvent( const SDL_Event& Event );
我们为HandleEvent()
函数增加了两个案例标签,以便我们可以分派触摸事件:
case SDL_MOUSEBUTTONDOWN:
OnTouch( Event.button.x, Event.button.y, true );
break;
case SDL_MOUSEBUTTONUP:
OnTouch( Event.button.x, Event.button.y, false );
break;
在 C++11 之前,将类似 C 的函数指针和类成员函数指针包装在单个对象中并不是一件容易的事,需要一些重量级的模板库,如boost::bind
。现在,SDL 库中的std::function
对象正好符合我们的需求。
我们在这里实现的唯一交互式对象是clUIButton
。当用户点击这样的对象时,会执行自定义操作。该操作的代码可以位于独立函数、成员函数或 lambda 表达式中。例如,我们创建一个Exit
按钮,代码可能如下所示:
ExitBtn->SetTouchHandler(
[](int x, int y )
{
LOGI( "Exiting" );
g_Window->RequestExit();
return true;
}
);
clUIButton
类必须包含std::function
字段,OnTouch()
方法在发生点击时可选地调用此函数。
实现 UI 类
clUIStatic
视图是clUIView
的派生类,重写了PreDrawView()
方法:
class clUIStatic: public clUIView
{
public:
clUIStatic() : m_BackgroundColor( 255, 255, 255, 255 ) {}
virtual void SetBackgroundColor( const ivec4& C )
{ m_BackgroundColor = C;};
protected:
virtual void PreDrawView( const clPtr<iCanvas>& C ) override
{
C->SetColor( m_BackgroundColor );
C->Rect(m_ScreenX, m_ScreenY, m_Width, m_Height, true);
clUIView::PreDrawView( C );
}
private:
ivec4 m_BackgroundColor;
};
clUIButton
类在clUIStatic
渲染之上添加了自定义触摸事件处理:
typedef std::function<bool(int x, int y)> sTouchHandler;
class clUIButton: public clUIStatic
{
public:
clUIButton(): m_OnTouchHandler(nullptr) {}
virtual bool OnTouch( int x, int y, bool Pressed ) override
{
if( IsPointOver( x, y ) )
{
if(!Pressed && m_OnTouchHandler )
return m_OnTouchHandler(x, y);
}
return false;
}
virtual void SetTouchHandler(const sTouchHandler&& H)
{ m_OnTouchHandler = H; }
private:
sTouchHandler m_OnTouchHandler;
};
现在,我们的迷你用户界面可以在应用程序中使用。
在应用程序中使用视图
下面是一个简短的代码片段,它创建了一个按钮,并在点击该按钮时退出应用程序:
auto MsgBox = make_intrusive<clUIButton>();
MsgBox->SetParentFractionX( 0.5f );
MsgBox->SetParentFractionY( 0.5f );
MsgBox->SetAlignmentV( eAlignV_Center );
MsgBox->SetAlignmentH( eAlignH_Center );
MsgBox->SetBackgroundColor( ivec4( 255, 255, 255, 255) );
MsgBox->SetTitle("Exit");
MsgBox->SetTouchHandler( [](int x, int y )
{
LOGI( "Exiting" );
g_Window->RequestExit();
return true;
}
);
完整的源代码可以在1_SDL2UI
示例中找到。除了本章讨论的细节之外,源代码还包含了一个基本的布局机制,以便视图可以拥有相对坐标和大小。想要了解这个附加功能,请查看LayoutController.cpp
和LayoutController.h
。
总结
在本章中,我们学习了如何用 C++实现并渲染基本用户界面,使用 FreeType 库渲染 UTF-8 文本,并以*台无关的方式处理用户输入。我们将在最后一章使用这些功能来实现一个跨*台游戏应用。现在,让我们回到在第六章,OpenGL ES 3.1 and Cross-platform Rendering开始讨论的 3D 渲染话题,并在这些抽象之上实现一个渲染引擎。
第八章:编写渲染引擎
在前面的章节中,我们学习了如何组织一个在移动和桌面 OpenGL 之上的薄抽象层。现在,我们可以进入实际的渲染领域,并使用这层来实现一个能够渲染从文件加载的几何图形的 3D 渲染框架,使用材质、光线和阴影。
场景图
场景图是一种常用的数据结构,用于构建空间图形场景的分层表示。第六章《OpenGL ES 3.1 与跨*台渲染》中介绍类的主要局限在于,它们缺乏对整个场景的信息。这些类的用户必须对变换、状态更改和依赖关系进行临时记账,使得实现和支持任何相对复杂的场景变得非常具有挑战性。此外,除非可以访问当前帧的整个场景信息,否则许多渲染优化都无法完成。
在我们当前的低级实现中,我们使用clVertexArray
类描述所有可见实体,并通过clGLSLShaderProgram
类访问着色器程序进行渲染,这需要手动绑定矩阵和着色器参数。让我们学习如何将这些属性组合到一个更高级的数据结构中。首先,我们将从场景图节点开始。
clSceneNode
类包含了局部和全局变换以及一个子节点向量。这些字段是受保护的,我们通过设置器和获取器来访问它们:
class clSceneNode: public iIntrusiveCounter
{
protected:
mat4 m_LocalTransform;
mat4 m_GlobalTransform;
std::vector< clPtr<clSceneNode> > m_ChildNodes;
当我们需要层次结构时,我们必须区分节点的全局和局部变换。从用户的角度来看,局部变换很容易理解。这定义了一个节点相对于其父节点在分层空间结构中的位置和方向。全局变换用于渲染几何体。本质上,它将几何体从模型坐标系转换到世界坐标系。局部变换可以直观地手动修改,而全局变换应随后重新评估。clSceneNode
的构造函数设置默认的变换值:
public:
clSceneNode():
m_LocalTransform( mat4::Identity() ),
m_GlobalTransform( mat4::Identity() ) {}
clSceneNode
类提供了设置器和获取器,用于访问和修改变换矩阵。实现很简单。然而,它相当繁琐,因此这里只引用了局部变换矩阵的方法。查看源代码1_SceneGraphRenderer
以获取完整信息:
void SetLocalTransform( const mat4& Mtx )
{ m_LocalTransform = Mtx; }
const mat4& GetLocalTransformConst() const
{ return m_LocalTransform; }
mat4& GetLocalTransform()
{ return m_LocalTransform; };
此外,我们需要一种方法将子节点添加到此场景节点。我们当前的实现非常简单:
virtual void Add( const clPtr<clSceneNode>& Node )
{
m_ChildNodes.push_back( Node );
}
然而,这种方法将来可以扩展,以允许某些优化。例如,我们可以一旦添加新节点,就将场景图的某些部分标记为“脏”。这将允许我们保留从场景图构建的帧间渲染队列。
有时,需要直接设置全局变换矩阵。例如,如果你想在 3D 应用程序中使用物理模拟库。完成后,应该重新计算局部变换。RecalculateLocalFromGlobal()
方法为层次结构中的每个节点计算相对局部变换。对于根节点,局部和全局变换是重合的。对于子节点,每个全局变换矩阵必须与父节点的逆全局变换相乘:
void RecalculateLocalFromGlobal()
{
mat4 ParentInv = m_GlobalTransform.GetInversed();
for ( auto& i : m_ChildNodes )
{
我们将父节点的全局逆变换与当前节点的全局变换相乘:
i->SetLocalTransform( ParentInv * i->GetGlobalTransform() );
过程在层次结构中逐级重复:
i->RecalculateLocalFromGlobal();
}
}
clSceneNode
声明中还有一个更有趣的东西。这就是纯虚拟方法AcceptTraverser()
。为了渲染场景图,使用了名为访问者设计模式的技术(en.wikipedia.org/?title=Visitor_pattern
):
virtual void AcceptTraverser(iSceneTraverser* Traverser) = 0;
iSceneTraverser
接口用于定义场景图上的不同操作。由于不同类型的场景图节点的数量是有限且恒定的,我们可以通过实现iSceneTraverser
接口简单地添加新操作:
class iSceneTraverser: public iIntrusiveCounter
{
public:
virtual void Traverse( clPtr<clSceneNode> Node );
virtual void Reset() = 0;
接口被声明为clSceneNode
所有后代的友元,以允许直接访问这些类的字段:
friend class clSceneNode;
friend class clMaterialNode;
friend class clGeometryNode;
protected:
virtual void PreAcceptSceneNode( clSceneNode* Node ) {};
virtual void PostAcceptSceneNode( clSceneNode* Node ) {};
virtual void PreAcceptMaterialNode( clMaterialNode* Node ) {};
virtual void PostAcceptMaterialNode( clMaterialNode* Node ){};
virtual void PreAcceptGeometryNode( clGeometryNode* Node ) {};
virtual void PostAcceptGeometryNode( clGeometryNode* Node ){};
};
Traverse()
的实现被所有遍历器共享。它重置遍历器并调用虚拟方法clSceneNode::AcceptTraverser()
:
void iSceneTraverser::Traverse( clPtr<clSceneNode> Node )
{
if ( !Node ) return;
Reset();
Node->AcceptTraverser( this );
}
在iSceneTraverser
的声明中,你可以看到两种额外的场景节点类型。clSceneNode
对象的树可以保持空间变换的层次结构。显然,这还不足以渲染任何东西。为此,我们至少需要几何数据和一个材质。
为了这个目的,我们再声明两个类:clMaterialNode
和clGeometryNode
。
本章的第一个示例中,材质将只包含环境光和漫反射颜色(en.wikipedia.org/wiki/Phong_shading
):
struct sMaterial
{
public:
sMaterial()
: m_Ambient( 0.2f )
, m_Diffuse( 0.8f )
, m_MaterialClass()
{}
vec4 m_Ambient;
vec4 m_Diffuse;
m_MaterialClass
字段包含一个材质标识符,可以用来区分特殊材质,例如,为粒子渲染启用 alpha 透明度:
std::string m_MaterialClass;
};
现在,可以声明一个材质场景节点。它是一个简单的数据容器:
class clMaterialNode: public clSceneNode
{
public:
clMaterialNode() {};
sMaterial& GetMaterial() { return m_Material; }
const sMaterial& GetMaterial() const { return m_Material; }
void SetMaterial( const sMaterial& Mtl ) { m_Material = Mtl; }
virtual void AcceptTraverser(iSceneTraverser* Traverser) override;
private:
sMaterial m_Material;
};
让我们看看AcceptTraverser()
方法的实现。它非常简单且高效:
void clMaterialNode::AcceptTraverser( iSceneTraverser* Traverser )
{
Traverser->PreAcceptSceneNode( this );
Traverser->PreAcceptMaterialNode( this );
AcceptChildren( Traverser );
Traverser->PostAcceptMaterialNode( this );
Traverser->PostAcceptSceneNode( this );
}
几何节点更为复杂。这是因为clVertexAttribs
中的 API 独立几何数据表示应该被输入到clGLVertexArray
的实例中:
class clGeometryNode: public clSceneNode
{
public:
clGeometryNode() {};
clPtr<clVertexAttribs> GetVertexAttribs() const
{ return m_VertexAttribs; }
void SetVertexAttribs( const clPtr<clVertexAttribs>& VA )
{ m_VertexAttribs = VA; }
这里,我们以懒惰的方式将几何数据输入到 OpenGL 中:
clPtr<clGLVertexArray> GetVA() const
{
if ( !m_VA )
{
m_VA = make_intrusive<clGLVertexArray>();
m_VA->SetVertexAttribs( m_VertexAttribs );
}
return m_VA;
}
virtual void AcceptTraverser(iSceneTraverser* Traverser) override;
private:
clPtr<clVertexAttribs> m_VertexAttribs;
mutable clPtr<clGLVertexArray> m_VA;
};
AcceptTraverser()
的实现与clMaterialNode
内部的实现非常相似。只需查看捆绑的源代码即可。
如你所见,这一大堆场景节点类只不过是一个简单的数据容器。实际操作在遍历器类中发生。iSceneTraverser
的第一个实现是clTransformUpdateTraverser
类,它更新每个场景节点的全局变换——即相对于图根的变换:
class clTransformUpdateTraverser: public iSceneTraverser
{
private:
std::vector<mat4> m_ModelView;
私有字段m_ModelView
包含一个作为std::vector
实现的矩阵栈。这个栈的顶部元素是节点的当前全局变换。Reset()
方法清除栈,并在栈上推入单位矩阵,这后来用作根场景节点的全局变换:
public:
clTransformUpdateTraverser(): m_ModelView() {}
virtual void Reset() override
{
m_ModelView.clear();
m_ModelView.push_back( mat4::Identity() );
}
PreAcceptSceneNode()
方法将当前全局变换的新值推送到m_ModelView
栈上,然后将其作为传入节点的全局变换使用:
protected:
virtual void PreAcceptSceneNode( clSceneNode* Node ) override
{
m_ModelView.push_back( Node->GetLocalTransform() * m_ModelView.back() );
Node->SetGlobalTransform( m_ModelView.back() );
}
PostAcceptSceneNode()
方法从栈中弹出最顶层的、现在未使用的矩阵:
virtual void PostAcceptSceneNode( clSceneNode* Node ) override
{
m_ModelView.pop_back();
}
};
这个遍历器在每一帧开始时使用,在渲染任何几何体之前:
clTransformUpdateTraverser g_TransformUpdateTraverser;
clPtr<clSceneNode> g_Scene;
g_TransformUpdateTraverser.Traverse( g_Scene );
我们现在几乎准备好进行实际渲染了。为此,我们需要将场景图线性化为渲染操作的向量。让我们看看ROP.h
文件。每个渲染操作都是独立的几何体、材质和一系列变换矩阵。所需的矩阵存储在sMatrices
结构中:
struct sMatrices
{
投影、视图和模型矩阵从外部状态明确设置。
mat4 m_ProjectionMatrix;
mat4 m_ViewMatrix;
mat4 m_ModelMatrix;
使用UpdateMatrices()
方法更新光照和着色所需的其它矩阵:
mat4 m_ModelViewMatrix;
mat4 m_ModelViewMatrixInverse;
mat4 m_ModelViewProjectionMatrix;
mat4 m_NormalMatrix;
void UpdateMatrices()
{
m_ModelViewMatrix = m_ModelMatrix * m_ViewMatrix;
m_ModelViewMatrixInverse = m_ModelViewMatrix.GetInversed();
m_ModelViewProjectionMatrix = m_ModelViewMatrix * m_ProjectionMatrix;
m_NormalMatrix = mat4( m_ModelViewMatrixInverse.ToMatrix3().GetTransposed() );
}
};
这个结构可以根据需要轻松地扩展额外的矩阵。此外,将这个结构的值打包到统一缓冲区对象中非常方便。
现在,我们的渲染操作可以如下所示:
class clRenderOp
{
public:
clRenderOp( const clPtr<clGeometryNode>& G, const clPtr<clMaterialNode>& M):
m_Geometry(G), m_Material(M) {}
void Render( sMatrices& Matrices ) const;
clPtr<clGeometryNode> m_Geometry;
clPtr<clMaterialNode> m_Material;
};
clRenderOp::Render()
的最小化实现在ROP.cpp
中可以找到。那里定义了两个着色器。首先是一个通用的顶点着色器,用于将法线变换到世界空间:
static const char g_vShaderStr[] = R"(
uniform mat4 in_ModelViewProjectionMatrix;
uniform mat4 in_NormalMatrix;
uniform mat4 in_ModelMatrix;
in vec4 in_Vertex;
in vec2 in_TexCoord;
in vec3 in_Normal;
out vec2 v_Coords;
out vec3 v_Normal;
out vec3 v_WorldNormal;
void main()
{
v_Coords = in_TexCoord.xy;
v_Normal = mat3(in_NormalMatrix) * in_Normal;
v_WorldNormal = ( in_ModelMatrix * vec4( in_Normal, 0.0 ) ).xyz;
gl_Position = in_ModelViewProjectionMatrix * in_Vertex;
}
)";
然后,一个片段着色器使用单个方向光源进行简单的逐像素光照,该光源指向与相机相同的方向:
static const char g_fShaderStr[] = R"(
in vec2 v_Coords;
in vec3 v_Normal;
in vec3 v_WorldNormal;
out vec4 out_FragColor;
uniform vec4 u_AmbientColor;
uniform vec4 u_DiffuseColor;
void main()
{
vec4 Ka = u_AmbientColor;
vec4 Kd = u_DiffuseColor;
相机是静态定位的,光照在世界空间中进行:
vec3 L = normalize( vec3( 0.0, 0.0, 1.0 ) );
float d = clamp( dot( L, normalize(v_WorldNormal) ), 0.0, 1.0 );
vec4 Color = Ka + Kd * d;
out_FragColor = Color;
}
)";
静态全局变量保存了使用前述代码中提到的着色器链接的着色器程序:
clPtr<clGLSLShaderProgram> g_ShaderProgram;
实际的渲染代码更新所有矩阵,设置着色器程序的参数并渲染几何体:
void clRenderOp::Render( sMatrices& Matrices ) const
{
if ( !g_ShaderProgram )
{
g_ShaderProgram = make_intrusive<clGLSLShaderProgram>( g_vShaderStr, g_fShaderStr );
}
Matrices.m_ModelMatrix = this->m_Geometry->GetGlobalTransformConst();
Matrices.UpdateMatrices();
一旦渲染操作和统一变量的数量增加,以下代码段将成为瓶颈。它可以通过预缓存统一位置来进行优化:
g_ShaderProgram->Bind();
g_ShaderProgram->SetUniformNameVec4Array( "u_AmbientColor", 1, this->m_Material->GetMaterial().m_Ambient );
g_ShaderProgram->SetUniformNameVec4Array( "u_DiffuseColor", 1, this->m_Material->GetMaterial().m_Diffuse );
g_ShaderProgram->SetUniformNameMat4Array( in_ProjectionMatrix", 1, Matrices.m_ProjectionMatrix );
g_ShaderProgram->SetUniformNameMat4Array( "in_ViewMatrix", 1, Matrices.m_ViewMatrix );
g_ShaderProgram->SetUniformNameMat4Array( "in_ModelMatrix", 1, Matrices.m_ModelMatrix );
g_ShaderProgram->SetUniformNameMat4Array( "in_NormalMatrix", 1, Matrices.m_NormalMatrix );
g_ShaderProgram->SetUniformNameMat4Array( "in_ModelViewMatrix", 1, Matrices.m_ModelViewMatrix );
g_ShaderProgram->SetUniformNameMat4Array( "in_ModelViewProjectionMatrix", 1, Matrices.m_ModelViewProjectionMatrix );
this->m_Geometry->GetVA()->Draw( false );
}
让我们将场景图转换为渲染操作的向量,这样我们就可以看到实际渲染的图像。这是由clROPsTraverser
类完成的:
class clROPsTraverser: public iSceneTraverser
{
private:
std::vector<clRenderOp> m_RenderQueue;
std::vector<clMaterialNode*> m_Materials;
public:
clROPsTraverser()
: m_RenderQueue()
, m_Materials() {}
在构建新的渲染操作队列之前清除所有内容:
virtual void Reset() override
{
m_RenderQueue.clear();
m_Materials.clear();
}
返回对最*构造的队列的引用:
virtual const std::vector<clRenderOp>& GetRenderQueue() const
{
return m_RenderQueue;
}
现在,我们实现了iSceneTraverser
接口。这里的大多数方法将是空的:
protected:
virtual void PreAcceptSceneNode( clSceneNode* Node ) override
{
}
virtual void PostAcceptSceneNode( clSceneNode* Node ) override
{
}
当下一个几何节点进入时,使用材质堆栈中最顶层的材质并创建一个新的渲染操作:
virtual void PreAcceptGeometryNode( clGeometryNode* Node ) override
{
if ( !m_Materials.size() ) return;
m_RenderQueue.emplace_back( Node, m_Materials.front() );
}
virtual void PostAcceptGeometryNode( clGeometryNode* Node ) override
{
}
每当有clMaterialNode
进入时,都会更新材质堆栈:
virtual void PreAcceptMaterialNode( clMaterialNode* Node ) override
{
m_Materials.push_back( Node );
}
virtual void PostAcceptMaterialNode( clMaterialNode* Node ) override
{
m_Materials.pop_back();
}
};
最后,这个框架现在可以用来渲染实际的 3D 图形。示例场景在1_SceneGraphRenderer/main.cpp
中创建。首先,创建我们场景的根节点:
g_Scene = make_intrusive<clSceneNode>();
创建一个红色材质并将其绑定到材质场景节点:
auto MaterialNode = make_intrusive<clMaterialNode>();
sMaterial Material;
Material.m_Ambient = vec4( 0.1f, 0.0f, 0.0f, 1.0f );
Material.m_Diffuse = vec4( 0.9f, 0.0f, 0.0f, 1.0f );
MaterialNode->SetMaterial( Material );
让我们创建一个以原点为中心的立方体:
auto VA = clGeomServ::CreateAxisAlignedBox( vec3(-1), vec3(+1) );
g_Box= make_intrusive<clGeometryNode>();
g_Box->SetVertexAttribs( VA );
MaterialNode->Add( g_Box );
并将其添加到场景中:
g_Scene->Add( MaterialNode );
渲染直接且非常通用:
void OnDrawFrame()
{
static float Angle = 0.0f;
绕其对角线旋转立方体:
g_Box->SetLocalTransform( mat4::GetRotateMatrixAxis( Angle, vec3( 1, 1, 1 ) ) );
Angle += 0.01f;
更新节点的全局变换并构建一个渲染队列:
g_TransformUpdateTraverser.Traverse( g_Scene );
g_ROPsTraverser.Traverse( g_Scene );
const auto& RenderQueue = g_ROPsTraverser.GetRenderQueue();
设置矩阵。摄像机目前只提供了一个虚拟实现,它返回一个单位视图矩阵:
sMatrices Matrices;
Matrices.m_ProjectionMatrix = Math::Perspective( 45.0f, g_Window->GetAspect(), 0.4f, 2000.0f );
Matrices.m_ViewMatrix = g_Camera.GetViewMatrix();
在渲染帧之前清除屏幕:
LGL3->glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );
LGL3->glEnable( GL_DEPTH_TEST );
遍历渲染队列并渲染所有内容:
for ( const auto& ROP : RenderQueue )
{
ROP.Render( Matrices );
}
}
最终的图像将如下面的截图所示:
让我们扩展场景图渲染示例,加入灯光和阴影,并确保在 Android 上一切正常工作。
灯光和着色
为了渲染灯光和阴影,我们需要扩展前文展示的方法。接下来我们要讨论的代码示例是2_ShadowMaps
。阴影映射将使用投影阴影映射(en.wikipedia.org/wiki/Shadow_mapping
)。这样,场景从光线的视角渲染到一个离屏深度缓冲区。接下来,像往常一样渲染场景,并将每个片段投影到光的阴影映射上,并将与光相关的深度值与构建的阴影映射中的值进行比较。如果深度值大于阴影映射中相应的深度值,则片段处于阴影中。要进行离屏渲染,我们需要回顾第六章中引入的OpenGL ES 3.1 与跨*台渲染,并为其添加一个帧缓冲区抽象。
clGLFrameBuffer
类在GLFrameBuffer.h
中声明:
class clGLFrameBuffer: public iIntrusiveCounter
{
public:
clGLFrameBuffer():
FFrameBuffer(0),
FColorBuffer(),
FDepthBuffer(),
FColorBuffersParams(),
FHasDepthBuffer( false )
{}
virtual ~clGLFrameBuffer();
InitRenderTargetV()
方法接受一个包含宽度、高度和每个通道位数整数值的向量:
virtual void InitRenderTargetV( const ivec4& WidthHeightBitsPerChannel, const bool HasDepthBuffer );
此方法提供了对私有数据成员的访问,这些成员包括宽度、高度和每个通道的位数,它们被传递到InitRenderTargetV()
中:
virtual ivec4 GetParameters() const
{
return FColorBuffersParams;
}
帧缓冲区最重要的能力是能够将其内容作为纹理提供——一个颜色纹理和一个深度纹理:
virtual clPtr<clGLTexture> GetColorTexture() const
{
return FColorBuffer;
}
virtual clPtr<clGLTexture> GetDepthTexture() const
{
return FDepthBuffer;
}
Bind()
方法将此帧缓冲区设置为当前的 OpenGL 帧缓冲区:
virtual void Bind( int TargetIndex ) const;
virtual void UnBind() const;
受保护的CheckFrameBuffer()
方法用于根据 OpenGL 规范检查帧缓冲区的完整性:
protected:
void CheckFrameBuffer() const;
类的私有部分包含一个 OpenGL 缓冲区标识符,分别用于颜色和深度纹理的两个clGLTexture
对象,以及包含帧缓冲区参数的两个字段:
private:
GLuint FFrameBuffer;
clPtr<clGLTexture> FColorBuffer;
clPtr<clGLTexture> FDepthBuffer;
ivec4 FColorBuffersParams;
bool FHasDepthBuffer;
};
对于 Android 和其他*台正确构建帧缓冲区需要一些工作和仔细选择参数。让我们看看InitRenderTargetV()
成员函数的实现:
void clGLFrameBuffer::InitRenderTargetV( const ivec4& WidthHeightBitsPerChannel, const bool HasDepthBuffer )
{
首先,我们在私有数据成员中存储帧缓冲区的参数。这些值稍后用于视口宽高比计算:
FColorBuffersParams = WidthHeightBitsPerChannel;
FHasDepthBuffer = HasDepthBuffer;
接下来,我们将调用 OpenGL 函数创建帧缓冲区对象:
LGL3->glGenFramebuffers( 1, &FFrameBuffer );
创建帧缓冲区对象后,我们可以将其绑定为当前帧缓冲区以设置其属性:
Bind( 0 );
创建并附加一个颜色纹理到帧缓冲区:
FColorBuffer = make_intrusive<clGLTexture>();
int Width = FColorBuffersParams[0];
int Height = FColorBuffersParams[1];
这里 Android 和桌面实现之间的唯一区别在于缓冲数据格式的选择。OpenGL 4 Core Profile 要求显式指定内部格式和深度格式的位数,而 OpenGL ES 3 则分别希望通用的GL_RGBA
和GL_DEPTH_COMPONENT
。我们以*台特定的方式声明两个常量:
#if defined( OS_ANDROID )
const Lenum InternalFormat = GL_RGBA;
auto DepthFormat = GL_DEPTH_COMPONENT;
#else
const Lenum InternalFormat = GL_RGBA8;
auto DepthFormat = GL_DEPTH_COMPONENT24;
#endif
我们将调用clGLTexture
的SetFormat()
方法来设置颜色纹理的格式:
FColorBuffer->SetFormat( GL_TEXTURE_2D, InternalFormat, GL_RGBA, Width, Height );
AttachToCurrentFB()
方法将创建的颜色纹理附加到当前绑定的帧缓冲区。GL_COLOR_ATTACHMENT0
的值指定了一个 OpenGL 附着点:
FColorBuffer->AttachToCurrentFB( GL_COLOR_ATTACHMENT0 );
阴影图包含深度缓冲区的值,因此我们按需以下列方式创建深度纹理:
if ( HasDepthBuffer )
{
FDepthBuffer = make_intrusive<clGLTexture>();
深度缓冲区应与颜色缓冲区具有相同的尺寸:
int Width = FColorBuffersParams[0];
int Height = FColorBuffersParams[1];
深度缓冲区的设置与颜色缓冲区类似:
FDepthBuffer->SetFormat( GL_TEXTURE_2D, DepthFormat, GL_DEPTH_COMPONENT, Width, Height );
FDepthBuffer->AttachToCurrentFB( GL_DEPTH_ATTACHMENT );
}
为了确保正确操作,我们将检查错误代码并解绑缓冲区:
CheckFrameBuffer();
UnBind();
}
CheckFrameBuffer()
成员函数使用 OpenGL 调用来检查当前帧缓冲区的状态:
void clGLFrameBuffer::CheckFrameBuffer() const
{
Bind( 0 );
GLenum FBStatus = LGL3->glCheckFramebufferStatus( GL_FRAMEBUFFER );
将错误代码转换为字符串并将其打印到系统日志中:
switch ( FBStatus )
{
case GL_FRAMEBUFFER_COMPLETE: // Everything's OK
break;
case GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT:
LOGI( "GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT" );
break;
case GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT:
LOGI("GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT" );
break;
OpenGL ES 缺少 OpenGL 的一些功能。这里,我们省略它们以使代码可移植:
#if !defined(OS_ANDROID)
case GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER:
LOGI( "GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER" );
break;
case GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER:
LOGI( "GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER" );
break;
#endif
case GL_FRAMEBUFFER_UNSUPPORTED:
LOGI( "GL_FRAMEBUFFER_UNSUPPORTED" );
break;
default:
LOGI( "Unknown framebuffer error: %x", FBStatus );
}
默认情况下,不打印任何内容:
UnBind();
}
让我们继续前进,以便我们可以使用这个类。
光照和光照节点
将光源表示为 3D 场景的一部分非常方便。当我们提到“3D 场景”时,我们指的是场景图。为了将光源附加到场景图,我们需要一个特殊的节点。以下是持有指向具有所有光照属性的iLight
的clLightNode
类:
class clLightNode: public clSceneNode
{
public:
clLightNode() {}
clPtr<iLight> GetLight() const
{
return m_Light;
}
void SetLight( const clPtr<iLight>& L )
{
m_Light = L;
}
virtual void AcceptTraverser( iSceneTraverser* Traverser )
override;
clPtr<iLight> m_Light;
};
AcceptTraverser()
方法与clGeometryNode
和clMaterialNode
中的方法类似。但这次,我们将调用iSceneTraverser
的PreAcceptLightNode()
和PostAcceptLightNode()
方法:
void clLightNode::AcceptTraverser( iSceneTraverser* Traverser )
{
Traverser->PreAcceptSceneNode( this );
Traverser->PreAcceptLightNode( this );
AcceptChildren( Traverser );
Traverser->PostAcceptLightNode( this );
Traverser->PostAcceptSceneNode( this );
}
这种新的场景节点类型迫使我们扩展iSceneTraverser
的接口:
protected:
friend class clLightNode;
virtual void PreAcceptLightNode( clLightNode* Node ) {}
virtual void PostAcceptLightNode( clLightNode* Node ) {}
现在遍历器可以以特殊方式处理光照节点。我们将利用这一能力在每帧基础上维护场景中的活动光照列表。
iLight
类封装了光参数。它计算了光源所需的投影和视图矩阵,将它们传递给着色器程序并持有一个阴影贴图。我们应该注意到,为可能未使用的光源保持一个初始化的阴影贴图肯定不是最优的。在我们这个最小化的示例中,最少可以做到将阴影贴图的创建推迟到真正需要的时候。在我们的示例中,我们只处理聚光灯。然而,这种方法可以很容易地扩展到方向光和点光源:
class iLight: public iIntrusiveCounter
{
public:
iLight():
m_Ambient(0.2f),
m_Diffuse(0.8f),
m_Position(0),
m_Direction(0.0f, 0.0f, 1.0f),
如果你想要实现多种类型的光源,建议将这个字段推送到表示聚光灯的类中。由于我们的示例只有单一类型的光源,在这里放置这个值是合理的折中:
m_SpotOuterAngle(45.0f)
{}
UpdateROPUniforms()
方法更新了阴影贴图渲染所需的着色器程序中的所有 uniform。在完成iLight
之后,将详细描述clMaterialSystem
类:
void UpdateROPUniforms( const std::vector<clRenderOp>& ROPs, const clPtr<clMaterialSystem>& MatSys, const clPtr<clLightNode>& LightNode ) const;
为了从光的角度渲染场景,我们需要计算两个矩阵。第一个是标准的look-at矩阵,定义了光的视图矩阵;第二个是透视投影矩阵,定义了光的截锥体:
mat4 GetViewForShadowMap() const
{
return Math::LookAt( m_Position, m_Position + m_Direction, vec3( 0, 0, 1 ) );
}
mat4 GetProjectionForShadowMap() const
{
float NearCP = 0.1f;
float FarCP = 1000.0f;
return Math::Perspective( 2.0f * this->m_SpotOuterAngle, 1.0f, NearCP, FarCP );
}
GetShadowMap()
函数返回一个延迟初始化的阴影贴图缓冲区,该缓冲区附加到此光源:
clPtr<clGLFrameBuffer> iLight::GetShadowMap() const
{
if ( !m_ShadowMap )
{
m_ShadowMap = make_intrusive<clGLFrameBuffer>();
m_ShadowMap->InitRenderTargetV(
{ ivec4(1024, 1024, 8, 0) },
true
);
}
return m_ShadowMap;
}
光源的性质包括在简单光照模型中使用的漫反射和环境颜色,用于视图矩阵计算的位置和方向,以及聚光灯锥角:
public:
vec4 m_Ambient;
vec4 m_Diffuse;
vec3 m_Position;
vec3 m_Direction;
float m_SpotOuterAngle;
最后,我们声明一个持有阴影贴图的帧缓冲区:
mutable clPtr<clGLFrameBuffer> m_ShadowMap;
};
让我们看看着色器程序的 uniform 是如何更新的。这发生在UpdateROPUniforms()
中,该函数在每个渲染操作之前对每个阴影贴图进行渲染时被调用:
void iLight::UpdateROPUniforms( const std::vector<clRenderOp>& ROPs, const clPtr<clMaterialSystem>& MatSys, const clPtr<clLightNode>& LightNode ) const
{
mat4 LightMV = this->GetViewForShadowMap();
mat4 LightProj = GetProjectionForShadowMap();
mat4 Mtx = LightNode->GetGlobalTransformConst();
vec3 Pos = ( Mtx * vec4( this->m_Position, 1.0f ) ).XYZ();
vec3 Dir = ( Mtx * vec4( this->m_Direction, 0.0f ) ).XYZ();
auto AmbientSP = MatSys->GetShaderProgramForPass( ePass_Ambient );
AmbientSP->Bind();
AmbientSP->SetUniformNameVec3Array( "u_LightPos", 1, Pos );
AmbientSP->SetUniformNameVec3Array( "u_LightDir", 1, Dir );
auto LightSP = MatSys->GetShaderProgramForPass( ePass_Light );
LightSP->Bind();
LightSP->SetUniformNameVec3Array( "u_LightPos", 1, Pos );
LightSP->SetUniformNameVec3Array( "u_LightDir", 1, Dir );
LightSP->SetUniformNameVec4Array( "u_LightDiffuse", 1, this->m_Diffuse );
mat4 ScaleBias = GetProjScaleBiasMat();
mat4 ShadowMatrix = ( Mtx * LightMV * LightProj ) * ScaleBias;
LightSP->SetUniformNameMat4Array( "in_ShadowMatrix", 1, ShadowMatrix );
this->GetShadowMap()->GetDepthTexture()->Bind( 0 );
}
GetProjScaleBiasMat()
辅助例程返回一个缩放矩阵,该矩阵将[-1..1]标准化设备坐标映射到[0..1]范围:
mat4 GetProjScaleBiasMat()
{
return mat4( 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.0f, 0.5f, 0.5f, 0.5f, 1.0 );
}
在这段代码中提到的clMaterialSystem
类需要一些额外的解释。
材质系统
在我们之前的示例1_SceneGraphRenderer
中,我们使用单个着色器程序来渲染场景中的所有对象。现在,我们的渲染器将变为多通道。我们需要创建阴影贴图,然后渲染带阴影的对象并计算光照。这是通过在三个不同的渲染通道中使用三个不同的着色器程序完成的。为了区分通道,我们定义了ePass
枚举如下:
enum ePass
{
ePass_Ambient,
ePass_Light,
ePass_Shadow
};
为了基于通道和材质属性处理不同的着色器程序,我们实现了clMaterialSystem
类:
class clMaterialSystem: public iIntrusiveCounter
{
public:
clMaterialSystem();
GetShaderProgramForPass()
方法返回指定通道在std::map
中存储的着色器程序:
clPtr<clGLSLShaderProgram> GetShaderProgramForPass(ePass P)
{
return m_ShaderPrograms[ P ];
}
private:
std::map<ePass, clPtr<clGLSLShaderProgram>> m_ShaderPrograms;
};
这个类的构造函数创建了渲染所需的每个着色器程序,并将其插入到映射中:
clMaterialSystem::clMaterialSystem()
{
m_ShaderPrograms[ ePass_Ambient ] = make_intrusive<clGLSLShaderProgram>( g_vShaderStr, g_fShaderAmbientStr );
m_ShaderPrograms[ ePass_Light ] = make_intrusive<clGLSLShaderProgram>( g_vShaderStr, g_fShaderLightStr );
m_ShaderPrograms[ ePass_Shadow ] = make_intrusive<clGLSLShaderProgram>( g_vShaderShadowStr, g_fShaderShadowStr );
}
注意
在此示例中,映射可以用一个简单的 C 风格数组替换。但是,稍后我们将使用不同材质类型和不同的着色器程序,所以使用映射会更合适。
与之前的示例一样,每个着色器的源代码存储在一个静态字符串变量中。这次,代码有点复杂。顶点着色器源代码在环境传递和每个光线传递之间共享:
static const char g_vShaderStr[] = R"(
uniform mat4 in_ModelViewProjectionMatrix;
uniform mat4 in_NormalMatrix;
uniform mat4 in_ModelMatrix;
uniform mat4 in_ShadowMatrix;
in vec4 in_Vertex;
in vec2 in_TexCoord;
in vec3 in_Normal;
out vec2 v_Coords;
out vec3 v_Normal;
out vec3 v_WorldNormal;
out vec4 v_ProjectedVertex;
out vec4 v_ShadowMapCoord;
同一个函数在 C++代码中被用来将值从[-1..1]范围转换到[0..1]范围:
mat4 GetProjScaleBiasMat()
{
return mat4(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 0.5, 0.0,
0.5, 0.5, 0.5, 1.0 );
}
值传递给后续的片段着色器:
void main()
{
v_Coords = in_TexCoord.xy;
v_Normal = mat3(in_NormalMatrix) * in_Normal;
v_WorldNormal = ( in_ModelMatrix * vec4( in_Normal, 0.0 ) ).xyz;
v_ProjectedVertex = GetProjScaleBiasMat() * in_ModelViewProjectionMatrix * in_Vertex;
v_ShadowMapCoord = in_ShadowMatrix * in_ModelMatrix * in_Vertex;
gl_Position = in_ModelViewProjectionMatrix * in_Vertex;
}
)";
这是环境传递的片段着色器。只需将环境色输出到帧缓冲区,我们就完成了:
static const char g_fShaderAmbientStr[] = R"(
in vec2 v_Coords;
in vec3 v_Normal;
in vec3 v_WorldNormal;
out vec4 out_FragColor;
uniform vec4 u_AmbientColor;
uniform vec4 u_DiffuseColor;
void main()
{
out_FragColor = u_AmbientColor;
}
)";
每个光线传递的片段着色器根据光线的参数和阴影映射计算实际的光照和着色。这就是为什么它比我们之前的着色器要长得多:
static const char g_fShaderLightStr[] = R"(
in vec2 v_Coords;
in vec3 v_Normal;
in vec3 v_WorldNormal;
in vec4 v_ProjectedVertex;
in vec4 v_ShadowMapCoord;
out vec4 out_FragColor;
uniform vec4 u_AmbientColor;
uniform vec4 u_DiffuseColor;
uniform vec3 u_LightPos;
uniform vec3 u_LightDir;
uniform vec4 u_LightDiffuse;
uniform sampler2D Texture0;
阴影是使用称为百分比接*过滤的技术计算得出的。如果我们使用简单的阴影映射方法,得到的阴影将有很多锯齿。百分比接*过滤(PCF)的理念是在当前像素周围的阴影映射中进行采样,并将其深度与所有样本进行比较。通过*均比较的结果(而不是采样的结果),我们可以得到光与影之间更*滑的边缘。我们的示例使用了带有 26 个抽头的 5 X 5 PCF 滤波器:
float PCF5x5( const vec2 ShadowCoord, float Depth )
{
float Size = 1.0 / float( textureSize( Texture0, 0 ).x );
float Shadow =( Depth >= texture( Texture0, ShadowCoord ).r ) ? 1.0 : 0.0;
for ( int v=-2; v<=2; v++ ) for ( int u=-2; u<=2; u++ )
{
Shadow += ( Depth >= texture( Texture0, ShadowCoord + Size * vec2(u, v) ).r ) ? 1.0 : 0.0;
}
return Shadow / 26.0;
}
这是评估给定片段是否在阴影中的函数:
float ComputeSpotLightShadow()
{
进行透视除法,将阴影映射投影到物体上:
vec4 ShadowCoords4 = v_ShadowMapCoord / v_ShadowMapCoord.w;
if ( ShadowCoords4.w > 0.0 )
{
vec2 ShadowCoord = vec2( ShadowCoords4 );
float DepthBias = -0.0002;
float ShadowSample = 1.0 - PCF5x5( ShadowCoord, ShadowCoords4.z + DepthBias );
DepthBias
系数用于防止阴影痘痘。以下是同一场景的两次渲染,一次是零DepthBias
(左),一次是-0.0002
(右):
通常,这需要手动调整,并应该是光线参数的一部分。查看以下链接,了解更多关于如何改善阴影的想法:
msdn.microsoft.com/en-us/library/windows/desktop/ee416324(v=vs.85).aspx
。
现在,乘以系数并返回结果值:
float ShadowCoef = 0.3;
return ShadowSample * ShadowCoef;
}
return 1.0;
}
现在,我们可以根据实际的光线方向及其阴影映射计算一个简单的光照模型:
void main()
{
vec4 Kd = u_DiffuseColor * u_LightDiffuse;
vec3 L = normalize( u_LightDir );
vec3 N = normalize( v_WorldNormal );
float d = clamp( dot( -L, N ), 0.0, 1.0 );
vec4 Color = Kd * d * ComputeSpotLightShadow();
Color.w = 1.0;
out_FragColor = Color;
}
)";
要构建上一个着色器中使用的阴影映射,我们需要一个额外的渲染传递。对于每个光线,使用以下顶点和片段着色器:
static const char g_vShaderShadowStr[] = R"(
uniform mat4 in_ModelViewProjectionMatrix;
in vec4 in_Vertex;
void main()
{
gl_Position = in_ModelViewProjectionMatrix * in_Vertex;
}
)";
static const char g_fShaderShadowStr[] = R"(
out vec4 out_FragColor;
void main()
{
out_FragColor = vec4( 1, 1, 1, 1 );
}
)";
现在我们可以渲染一个更美观的图像,包含所有阴影和更精确的光照。让我们看看2_ShadowMaps/main.cpp
文件。
演示应用程序和渲染技术
新代码最重要的部分在OnDrawFrame()
方法中。它使用clForwardRenderingTechnique
类来渲染场景。让我们看看Technique.cpp
文件。
辅助函数RenderROPs()
用于渲染渲染操作的向量:
void RenderROPs( sMatrices& Matrices, const std::vector<clRenderOp>& RenderQueue, ePass Pass )
{
for ( const auto& ROP : RenderQueue )
{
ROP.Render( Matrices, g_MatSys, Pass );
}
}
现在,所有传递都可以用这个函数来描述。看看clForwardRenderingTechnique::Render()
函数。首先,让我们构建两个渲染队列,一个用于不透明物体,一个用于透明物体。透明物体是指其材质类别为字符串Particle
的物体。我们将在下一章中使用透明物体:
m_TransformUpdateTraverser.Traverse( Root );
m_ROPsTraverser.Traverse( Root );
const auto& RenderQueue = m_ROPsTraverser.GetRenderQueue();
auto RenderQueue_Opaque = Select( RenderQueue, []( const clRenderOp& ROP )
{
return ROP.m_Material->GetMaterial().m_MaterialClass != "Particle";
} );
auto RenderQueue_Transparent = Select( RenderQueue, []( const clRenderOp& ROP )
{
return ROP.m_Material->GetMaterial().m_MaterialClass == "Particle";
}
);
为着色器准备矩阵并清除 OpenGL 缓冲区:
sMatrices Matrices;
Matrices.m_ProjectionMatrix = Proj;
Matrices.m_ViewMatrix = View;
Matrices.UpdateMatrices();
LGL3->glClearColor( 0.0f, 0.0f, 0.0f, 0.0f );
LGL3->glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );
LGL3->glEnable( GL_DEPTH_TEST );
现在,使用它们的环境色渲染所有对象。这就是全部内容,环境传递不需要任何光照。作为副产品,我们将得到一个充满值的 Z 缓冲区,因此在后续传递中我们可以禁用深度写入:
LGL3->glDepthFunc( GL_LEQUAL );
LGL3->glDisablei( GL_BLEND, 0 );
RenderROPs( Matrices, RenderQueue_Opaque, ePass_Ambient, MatSys );
对于后续的每个光照传递过程,我们需要从场景中获取一个光照向量。从遍历器中获取它,并更新所有的阴影贴图:
auto Lights = g_ROPsTraverser.GetLights();
UpdateShadowMaps( Lights, RenderQueue );
UpdateShadowMaps()
函数遍历光照节点向量,并将阴影投射器渲染到相应的阴影贴图中:
void UpdateShadowMaps( const std::vector<clLightNode*>& Lights, const std::vector<clRenderOp>& ROPs )
{
LGL3->glDisable( GL_BLEND );
for ( size_t i = 0; i != Lights.size(); i++ )
{
sMatrices ShadowMatrices;
clPtr<iLight> L = Lights[ i ]->GetLight();
clPtr<clGLFrameBuffer> ShadowBuffer = L->GetShadowMap();
绑定并清除阴影贴图帧缓冲区:
ShadowBuffer->Bind( 0 );
LGL3->glClearColor( 0, 0, 0, 1 );
LGL3->glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );
光源知道其投影和视图矩阵。这段代码相当通用,可以扩展用于包括具有多个视锥体的光源在内的光类型:
LMatrix4 Proj = L->GetProjectionForShadowMap();
LMatrix4 MV = L->GetViewForShadowMap();
在着色器程序中更新 uniform 变量:
ShadowMatrices.m_ViewMatrix = MV;
ShadowMatrices.m_ProjectionMatrix = Proj;
ShadowMatrices.UpdateMatrices();
L->UpdateROPUniforms( ROPs, g_MatSys, Lights[i] );
渲染到阴影贴图并解绑帧缓冲区:
RenderROPs( ShadowMatrices, ROPs, ePass_Shadow );
ShadowBuffer->UnBind();
}
}
所有的阴影贴图现在都准备好在渲染代码中使用。让我们继续OnDrawFrame()
函数。每个光照传递会累积所有光源的光照,如下所示:
LGL3->glDepthFunc( GL_EQUAL );
LGL3->glBlendFunc( GL_ONE, GL_ONE );
LGL3->glEnablei( GL_BLEND, 0 );
for ( const auto& L : Lights )
{
L->GetLight()->UpdateROPUniforms( RenderQueue, MatSys, L );
RenderROPs( Matrices, RenderQueue, ePass_Light, MatSys );
}
最后但同样重要的是,为透明对象渲染环境光照:
LGL3->glBlendFunc(GL_SRC_ALPHA, GL_ONE);
LGL3->glDepthFunc(GL_LESS);
LGL3->glEnablei(GL_BLEND, 0);
LGL3->glDepthMask( GL_FALSE );
RenderROPs( Matrices, RenderQueue_Transparent, ePass_Ambient, MatSys );
不要忘记重置 OpenGL 状态。扩展渲染器的一个好主意是将深度测试、深度掩码、混合模式等状态封装到管道状态对象中,并且只在状态改变时更新管道状态。如果您想将示例扩展到完整的渲染代码,这个改进是必须的:
LGL3->glDepthMask( GL_TRUE );
我们已经涵盖了所有低级渲染代码。让我们提高一个层次,看看如何构建一个场景。
场景构建
我们的测试场景在main()
中构建,过程如下所示。首先实例化全局对象:
g_MatSys = make_intrusive<clMaterialSystem>();
g_Scene = make_intrusive<clSceneNode>();
g_Canvas = make_intrusive<clGLCanvas>( g_Window );
之后,设置材质和材质节点:
auto CubeMaterialNode = make_intrusive<clMaterialNode>();
{
sMaterial Material;
Material.m_Ambient = vec4( 0.2f, 0.0f, 0.0f, 1.0f );
Material.m_Diffuse = vec4( 0.8f, 0.0f, 0.0f, 1.0f );
CubeMaterialNode->SetMaterial( Material );
}
auto PlaneMaterialNode = make_intrusive<clMaterialNode>();
{
sMaterial Material;
Material.m_Ambient = vec4( 0.0f, 0.2f, 0.0f, 1.0f );
Material.m_Diffuse = vec4( 0.0f, 0.8f, 0.0f, 1.0f );
PlaneMaterialNode->SetMaterial( Material );
}
auto DeimosMaterialNode = make_intrusive<clMaterialNode>();
{
sMaterial Material;
Material.m_Ambient = vec4( 0.0f, 0.0f, 0.2f, 1.0f );
Material.m_Diffuse = vec4( 0.0f, 0.0f, 0.8f, 1.0f );
DeimosMaterialNode->SetMaterial( Material );
}
现在,我们可以使用一推箱子和一个从.obj
文件加载的 Deimos([en.wikipedia.org/wiki/Deimos_(moon)
](https://en.wikipedia.org/wiki/Deimos_(moon))的 3D 模型来创建场景几何:
{
auto VA = clGeomServ::CreateAxisAlignedBox( vec3(-0.5), vec3(+0.5) );
g_Box= make_intrusive<clGeometryNode>();
g_Box->SetVertexAttribs( VA );
CubeMaterialNode->Add( g_Box );
这个函数可以在Loader_OBJ.cpp
文件中找到,并解析 Wavefront OBJ 文件格式(en.wikipedia.org/wiki/Wavefront_.obj_file
):
auto DeimosNode = LoadOBJSceneNode( g_FS->CreateReader( "deimos.obj" ) );
DeimosNode->SetLocalTransform( mat4::GetScaleMatrix(vec3(0.01f, 0.01f, 0.01f)) * mat4::GetTranslateMatrix(vec3(1.1f, 1.1f, 0.0f)) );
DeimosMaterialNode->Add( DeimosNode );
}
{
auto VA = clGeomServ::CreateAxisAlignedBox( vec3(-2.0f, -2.0f, -1.0f), vec3(2.0f, 2.0f, -0.95f) );
auto Geometry = make_intrusive<clGeometryNode>();
Geometry->SetVertexAttribs( VA );
PlaneMaterialNode->Add( Geometry );
}
最后但同样重要的是,我们将向场景中添加两个光源,这将产生两个不同的阴影:
{
auto Light = make_intrusive<iLight>( );
Light->m_Diffuse = vec4( 0.5f, 0.5f, 0.5f, 1.0f );
Light->m_Position = vec3( 5, 5, 5 );
Light->m_Direction = vec3( -1, -1, -1 ).GetNormalized();
Light->m_SpotOuterAngle = 21;
g_LightNode = make_intrusive<clLightNode>( );
g_LightNode->SetLight( Light );
g_Scene->Add( g_LightNode );
}
{
auto Light = make_intrusive<iLight>();
Light->m_Diffuse = vec4( 0.5f, 0.5f, 0.5f, 1.0f );
Light->m_Position = vec3( 5, -5, 5 );
Light->m_Direction = vec3( -1, 1, -1 ).GetNormalized();
Light->m_SpotOuterAngle = 20;
auto LightNode = make_intrusive<clLightNode>();
LightNode->SetLight( Light );
g_Scene->Add( LightNode );
}
将所有内容组合在一起,并进入应用程序主循环:
g_Scene->Add( CubeMaterialNode );
g_Scene->Add( PlaneMaterialNode );
g_Scene->Add( DeimosMaterialNode );
while( g_Window && g_Window->HandleInput() )
{
OnDrawFrame();
g_Window->Swap();
}
结果应用程序渲染了以下图像,包含旋转的立方体和两个光源的阴影:
演示应用程序也可以在 Android 上运行。去试试吧!
用户与 3D 场景的交互
我们希望您尝试运行了2_ShadowMaps
示例。您可能已经注意到,3D 场景可以通过触摸屏上的手势或在桌面计算机上使用鼠标进行旋转。
这是使用clVirtualTrackball
类完成的,它通过根据提供的触摸点计算视图矩阵来模拟虚拟轨迹球:
class clVirtualTrackball
{
public:
clVirtualTrackball():
FCurrentPoint( 0.0f ),
FPrevPoint( 0.0f ),
FStarted( false )
{
FRotation.IdentityMatrix();
FRotationDelta.IdentityMatrix();
};
获取与新的触摸点相对应的视图矩阵:
virtual LMatrix4 DragTo( LVector2 ScreenPoint, float Speed, bool KeyPressed )
{
if ( KeyPressed && !FStarted )
{
StartDragging( ScreenPoint );
FStarted = KeyPressed;
return mat4::Identity();
}
FStarted = KeyPressed;
如果我们没有触摸屏幕,返回一个单位矩阵:
if ( !KeyPressed ) return mat4::Identity();
将触摸点投影到虚拟轨迹球上,并找到当前投影点与上一个投影点之间的距离:
FCurrentPoint = ProjectOnSphere( ScreenPoint );
LVector3 Direction = FCurrentPoint - FPrevPoint;
LMatrix4 RotMatrix;
RotMatrix.IdentityMatrix();
float Shift = Direction.Length();
如果距离不为零,计算并返回一个旋转矩阵:
if ( Shift > Math::EPSILON )
{
LVector3 Axis = FPrevPoint.Cross( FCurrentPoint );
RotMatrix.RotateMatrixAxis( Shift * Speed, Axis );
}
FRotationDelta = RotMatrix;
return RotMatrix;
}
LMatrix4& GetRotationDelta()
{
return FRotationDelta;
};
获取当前矩阵:
virtual LMatrix4 GetRotationMatrix() const
{
return FRotation * FRotationDelta;
}
static clVirtualTrackball* Create()
{
return new clVirtualTrackball();
}
当用户首次触摸屏幕时,重置轨迹球的状态:
private:
virtual void StartDragging( LVector2 ScreenPoint )
{
FRotation = FRotation * FRotationDelta;
FCurrentPoint = ProjectOnSphere( ScreenPoint );
FPrevPoint = FCurrentPoint;
FRotationDelta.IdentityMatrix();
}
投影数学计算如下:
LVector3 ProjectOnSphere( LVector2 ScreenPoint )
{
LVector3 Proj;
将标准化点坐标转换为-1.0...1.0
范围:
Proj.x = 2.0f * ScreenPoint.x - 1.0f;
Proj.y = -( 2.0f * ScreenPoint.y - 1.0f );
Proj.z = 0.0f;
float Length = Proj.Length();
Length = ( Length < 1.0f ) ? Length : 1.0f;
Proj.z = sqrtf( 1.001f - Length * Length );
Proj.Normalize();
return Proj;
}
LVector3 FCurrentPoint;
LVector3 FPrevPoint;
LMatrix4 FRotation;
LMatrix4 FRotationDelta;
bool FStarted;
};
该类在UpdateTrackball()
函数中使用,该函数是从OnDrawFrame()
中调用的:
void UpdateTrackball( float Speed )
{
g_Trackball.DragTo( g_MouseState.FPos, Speed, g_MouseState.FPressed );
}
void OnDrawFrame()
{
UpdateTrackball( 10.0f );
mat4 TrackballMtx = g_Trackball.GetRotationMatrix();
Matrices.m_ViewMatrix = TrackballMtx * g_Camera.GetViewMatrix();
}
这个类允许你在触摸屏上旋转 3D 场景,并可用于在设备上调试场景。
总结
在本章中,我们学习了如何在我们*台独立的 OpenGL 封装之上构建更高级的场景图抽象。我们可以创建带有材质和光源的场景对象,并使用光照和阴影渲染场景。在下一章中,我们将暂时离开渲染——好吧,不是完全离开——并学习如何用 C++实现游戏逻辑。
第九章:实现游戏逻辑
对本章最简短的描述只有两个字:状态机。在这里,我们将介绍一种常见的处理游戏代码与用户界面部分交互的方法。我们从 Boids 算法的实现开始,然后继续扩展我们在前几章中实现的用户界面。
鸟群
在许多游戏应用中,你通常会看到移动的对象相互碰撞、射击、相互追逐、可以被其他对象触摸或避开,或者产生类似的行为。对象的可见复杂行为通常可以分解为几个简单状态之间的相互操作。例如,在街机游戏中,敌人会随机四处漫游,直到它看到由玩家控制的角色。遭遇后,它会切换到追逐状态,在接*目标时可能会切换到射击或攻击状态。如果敌方单位感知到某些劣势,它可能会逃离玩家。追逐状态反过来不仅仅是将敌人指向玩家,同时也会避免与环境发生碰撞。每个对象在不同的状态下可能会有不同的动画或材质。让我们使用由 Craig Reynolds 发明的已确立的方法来实现追逐和漫游算法,这种方法被称为群体行为或鸟群(en.wikipedia.org/wiki/Boids
)。这种方法用于创建半意识群体或某些生物智能群体的印象。在本章中,我们广泛使用状态模式(en.wikipedia.org/wiki/State_pattern
)来定义复杂用户交互场景。
我们仅考虑一个二维游戏世界,并将每个对象(或群体成员)*似为一个带有速度的圆形。速度作为一个矢量,既有大小也有方向。每个对象遵循三条简单的规则来计算其期望速度(www.red3d.com/cwr/boids
):
-
避障:为了避免拥挤局部群体成员而偏离。
-
凝聚:朝向局部群体成员的*均朝向。
-
对齐:朝向局部群体成员的*均位置移动。
我们实施的附加规则或行为包括到达和漫游算法,这些可以作为我们行为机制实现的基本调试工具。
第一条规则,避障,是指将速度从障碍物以及其他群体成员的方向偏离,如下图所示:
第二条规则,凝聚,是指向局部群体成员的*均朝向,如下图所示:
第三条规则,Alignment,尝试调整附*物体的计算*均速度。它以这种方式影响一群同伴,使它们很快移动方向成为共线和同向的,如下图所示:
ArriveTo规则将速度方向设置为空间中预定义的目标点或区域。通过允许目标在空间中移动,我们可以创建一些复杂的行为。
群集算法的实现
为了实现上述行为,我们将考虑以下类层次结构:
单个鸟类由clBoid
类的实例表示,该实例保存了一个指向iBehaviour
实例的指针。iBehavior
接口包含一个单一的成员函数GetControl()
,它计算作用在鸟类上的即时力。由于力的大小可能取决于鸟类的状态,我们将原始的非拥有指针传递给clBoid
到GetControl()
中:
class iBehaviour: public iIntrusiveCounter
{
public:
virtual vec2 GetControl( float dt, clBoid* Boid ) = 0;
};
让我们考虑一下clBoid
类本身。它包含m_Pos
和m_Vel
字段,分别保存了鸟类的当前位置和速度。这些值是二维向量,但整个结构可以使用三组件向量扩展到 3D 逻辑:
class clBoid: public iActor
{
public:
vec2 m_Pos;
vec2 m_Vel;
m_Angle
字段是鸟类的即时方向,它从m_Vel
值计算而来。m_MaxVel
字段包含鸟类的最大速度:
float m_Angle;
float m_MaxVel;
m_Behaviour
字段保存了一个指向iBehaviour
实例的指针,该实例计算所需行为的控制力:
clPtr<iBehaviour> m_Behaviour;
由于鸟类在群体中移动,并且依赖于邻*鸟类的位置和速度来调整其速度,我们保留一个非拥有的指向父clSwarm
对象的指针,以避免智能指针之间的循环引用:
clSwarm* m_Swarm;
类的构造函数初始化默认值,并设置一个空的行为:
public:
clBoid():
m_Pos(), m_Vel(),
m_Angle(0.0f), m_MaxVel(1.0f),
m_Behaviour(), m_Swarm(nullptr)
{}
唯一的成员函数是Update()
,它计算作用在物体上的力:
virtual void Update( float dt ) override
{
if ( m_Behaviour )
{
vec2 Force = m_Behaviour->GetControl( dt, this );
计算完力后,根据牛顿定律,a = F/m,以及欧拉积分方法(en.wikipedia.org/wiki/Euler_method
)修改速度。鸟类的质量设置为常数1.0
。可以自由引入可变参数,观察它如何改变群体的视觉行为:
const float Mass = 1.0f;
vec2 Accel = Force / Mass;
m_Vel += Accel * dt;
}
为了使视觉效果可信,我们将可能的最大速度限制在0…m_MaxVel
区间内:
m_Vel = ClampVec2( m_Vel, m_MaxVel );
计算完速度后,更新鸟类的位置:
m_Pos += m_Vel * dt;
最后,鸟类的方向应该被评估为X
轴与m_Vel
向量之间的角度。这个值用于使用指向箭头在屏幕上渲染鸟类:
if ( m_Vel.SqrLength() > 0.0f )
{
m_Angle = atan2( m_Vel.y, m_Vel.x );
}
}
};
最简单的非静态行为是在突发随机脉冲影响下的随机移动。这称为漫游行为,在clWanderBehaviour
类中实现。GetControl()
方法计算一个在-1..+1 范围内具有两个随机分量的向量:
class clWanderBehaviour: public iBehaviour
{
public:
virtual vec2 GetControl( float dt, clBoid* Boid ) override
{
return vec2( RandomFloat() * 2.0f - 1.0f, RandomFloat() * 2.0f - 1.0f );
}
};
clGoToBehaviour
类实现了鸟群另一种有用的行为。给定m_Target
字段中的目标坐标,这种行为驱动可控鸟群到那个点。一旦鸟位于距离m_Target
的m_TargetRadius
范围内,移动就会停止:
class clGoToBehaviour: public iBehaviour
{
public:
m_Target
和m_TargetRadius
字段定义了目标点的位置和半径:
vec2 m_Target;
float m_TargetRadius;
m_VelGain
和m_PosGain
成员保存两个值,定义了当达到目标时鸟应该多快制动,以及鸟群与目标距离成比例加速的快慢:
float m_VelGain;
float m_PosGain;
构造函数设置默认值和非零增益:
clGoToBehaviour():
m_Target(),
m_TargetRadius(0.1f),
m_VelGain(0.05f),
m_PosGain(1.0f)
{}
GetControl()
例程计算了鸟的位置与目标之间的差值。这个差值乘以m_PosGain
,并用作控制力:
virtual vec2 GetControl( float dt, clBoid* Boid ) override
{
auto Delta = m_Target - Boid->m_Pos;
如果鸟群中的鸟位于m_TargetRadius
距离内,我们将返回零作为控制力的值:
if ( Delta.Length() < m_TargetRadius )
{
return vec2();
}
注意
通过将前面的行替换为return -m_VelGain * Boid->m_Vel / dt;
这行,可以实现一个视觉上有趣的制动效果。施加制动脉冲,通过减少一些分数来降低速度,从而实现速度的*滑指数衰减。在视觉上,鸟群在目标中心附**滑地停止。
计算出的脉冲在成员函数的末尾返回:
return Delta * m_PosGain;
}
};
偏题:辅助例程
在这里,我们应该描述在控制计算中使用的几个函数。
在前面的代码中,我们使用了ClampVec2()
例程,该例程计算向量V
的长度,将这个长度与MaxValue
进行比较,并返回相同的向量V
或其长度为MaxValue
的约束同轴版本:
inline vec2 ClampVec2(const vec2& V, float MaxValue)
{
float L = V.Length();
return (L > MaxValue) ? V.GetNormalized() * MaxValue : V;
}
另一组方法包括随机数生成例程。RandomFloat()
方法使用 C++11 标准库在 0…1 区间内生成均匀分布的浮点值:
std::random_device rd;
std::mt19937 gen( rd() );
std::uniform_real_distribution<> dis( 0.0, 1.0 );
float RandomFloat()
{
return static_cast<float>( dis( gen ) );
}
RandomVec2Range()
方法使用RandomFloat()
函数两次,以返回在指定区间内具有随机分量的向量:
vec2 RandomVec2Range( const vec2& Min, const vec2& Max )
{
return Min + vec2( RandomFloat() * ( Max - Min ).x,
RandomFloat() * ( Max - Min ).y );
}
群体行为
到目前为止,我们只定义了clWanderBehaviour
类。为了实现群集算法,我们需要一次性存储所有鸟的信息。这样的集合在这里称为群体。clSwarm
类保存一个clBoid
对象的向量,并实现了一些在鸟群控制计算中使用的例程:
class clSwarm: public iIntrusiveCounter
{
public:
std::vector< clPtr<clBoid> > m_Boids;
clSwarm() {}
为了调试和视觉演示目的,GenerateRandom()
方法分配了具有随机位置和零速度的clBoid
对象的数量:
void GenerateRandom( size_t N )
{
m_Boids.reserve( N );
for ( size_t i = 0; i != N; i++ )
{
m_Boids.emplace_back( make_intrusive<clBoid>() );
默认情况下,每只鸟都具有一种漫游行为:
m_Boids.back()->m_Behaviour = make_intrusive<clWanderBehaviour>();
m_Boids.back()->m_Swarm = this;
位置是随机的,并且每个坐标也在-1..+1 范围内保持:
m_Boids.back()->m_Pos = RandomVec2Range( vec2(-1, -1), vec2(1, 1) );
}
}
Update()
方法遍历集合并更新每个鸟类:
void Update( float dt )
{
for ( auto& i : m_Boids )
{
i->Update( dt );
}
}
分离或避障算法使用与其他鸟类的距离总和作为控制力。clSwarm::CalculateSeparation()
方法遍历鸟类集合并计算所需的和:
vec2 CalculateSeparation( clBoid* B, float SafeDistance )
{
vec2 Control;
for ( auto& i : m_Boids)
{
if ( i.GetInternalPtr() != B )
{
对于每个鸟类,除了作为参数传递的那个,我们计算位置差分:
auto Delta = i->m_Pos - B->m_Pos;
如果距离超过安全阈值,例如,如果鸟类与其他鸟类接*,我们向控制力中添加负的差分:
if ( Delta.Length() < SafeDistance )
{
Control += Delta;
}
}
}
return Control;
}
凝聚力算法中使用了类似的例程来计算邻*鸟类的*均位置:
vec2 CalculateAverageNeighboursPosition( clBoid* B )
{
int N = static_cast<int>( m_Boids.size() );
如果不止一个鸟类,我们才累加位置:
if ( N > 1 )
{
vec2 Avg(0, 0);
对鸟类列表进行遍历,我们可以得到位置的总和:
for ( auto& i : m_Boids )
{
if ( i.GetInternalPtr() != B )
{
Avg += i->m_Pos;
}
}
Avg *= 1.0f / (float)(N - 1);
return Avg;
}
在单个鸟类的情况下,我们使用它的位置。这样,凝聚力算法中的控制力将为零:
return B->m_Pos;
}
类似的过程也应用于速度:
vec2 CalculateAverageNeighboursVelocity( clBoid* B )
{
int N = (int)m_Boids.size();
if (N > 1)
{
vec2 Avg(0, 0);
for ( auto& i : m_Boids )
if ( i.GetInternalPtr() != B )
Avg += i->m_Vel;
Avg *= 1.0f / (float)(N - 1);
return Avg;
}
return B->m_Vel;
}
实用方法SetSingleBehaviour()
将群体中每个鸟类的行为设置为指定值:
void SetSingleBehaviour( const clPtr<iBehaviour>& B )
{
for ( auto& i : m_Boids )
{
i->m_Behaviour = B;
}
}
};
现在我们有了clSwarm
类,我们终于可以实施群聚行为了。clFlockingBehaviour
使用邻*鸟类的信息,并使用经典的 Boids 算法计算控制力:
class clFlockingBehaviour : public iBehaviour
{
与往常一样,构造函数设置默认参数:
public:
clFlockingBehaviour():
m_AlignmentGain(0.1f),
m_AvoidanceGain(2.0f),
m_CohesionGain(0.1f),
m_SafeDistance(0.5f),
m_MaxValue(1.0f)
{}
m_SafeDistance
字段定义了一个距离,在该距离上避障算法不作用:
float m_SafeDistance;
下一个字段包含每个群聚算法影响的权重:
float m_AvoidanceGain;
float m_AlignmentGain;
float m_CohesionGain;
virtual vec2 GetControl(float dt, clBoid* Boid) override
{
auto Swarm = Boid->m_Swarm;
第一步是分离和避障:
vec2 Sep = m_AvoidanceGain * Swarm->CalculateSeparation(Boid, m_SafeDistance);
第二步是对齐:
auto AvgPos = Swarm->CalculateAverageNeighboursPosition(Boid);
vec2 Alignment = m_AlignmentGain * (AvgPos - Boid->m_Pos);
第三步是凝聚力。转向邻居的*均位置:
auto AvgVel = Swarm->CalculateAverageNeighboursVelocity(Boid);
vec2 Cohesion = m_CohesionGain * (AvgVel - Boid->m_Vel);
最后,我们将这三个值相加,并保持力的大小在m_MaxValue
以下:
return ClampVec2( Sep + Alignment + Cohesion, m_MaxValue );
}
};
我们行为系统的最后润色是一个实现行为混合的类。clMixedBehaviour
类包含行为和行为相应权重因子的向量,这些权重因子表示在结果行为中使用了多少行为的控制力:
class clMixedBehaviour : public iBehaviour
{
public:
std::vector< clPtr<iBehaviour> > m_Behaviours;
std::vector<float> m_Weights;
AddBehaviour()
成员函数向容器中添加一个新的权重因子和行为:
void AddBehaviour( float Weight, const clPtr<iBehaviour>& B )
{
m_Weights.push_back( Weight );
m_Behaviours.push_back( B );
}
如类名所示,GetControl()
例程为包含的每个行为计算控制力,并将所有这些控制向量乘以适当的权重相加:
virtual vec2 GetControl(float dt, clBoid* Boid) override
{
vec2 Control;
for ( size_t i = 0; i < m_Behaviours.size(); i++)
{
Control += m_Weights[i] * m_Behaviours[i]->GetControl(dt, Boid);
}
return Control;
}
};
如我们所见,clFlockingBehaviour
类可以分解为避障、凝聚力和分离部分。我们决定不使书籍结构复杂化,并将群聚行为实现为单个类。请随意实验和混合这些子行为。
渲染群体模拟。
为了使用开发好的群体模拟系统,我们需要渲染单个鸟类。由于我们已经有了 OpenGL 3D 场景图渲染系统,我们用三角形网格表示每个鸟类,并为它们创建场景节点。让我们这样做:
class clSwarmRenderer
{
private:
m_Root
字段中的单个clSceneNode
对象作为整个群体的根场景节点:
clPtr<clSceneNode> m_Root;
保留指向 clSwarm
对象的指针,以将鸟群位置和角度与场景节点变换同步:
clPtr<clSwarm> m_Swarm;
每个鸟群个体的场景节点存储在 m_Boids
向量中:
std::vector< clPtr<clSceneNode> > m_Boids;
类的构造函数为群体中的每个鸟群个体创建了一个场景节点:
public:
explicit clSwarmRenderer( const clPtr<clSwarm> Swarm )
: m_Root( make_intrusive<clSceneNode>() )
, m_Swarm( Swarm )
{
m_Boids.reserve( Swarm->m_Boids.size() );
const float Size = 0.05f;
for ( const auto& i : Swarm->m_Boids )
{
m_Boids.emplace_back( make_intrusive<clSceneNode>() );
从视觉上看,鸟群个体是一个三角形,因此我们调用 clGeomServ::CreateTriangle()
来创建一个包含单个三角形的顶点数组:
auto VA = clGeomServ::CreateTriangle( -0.5f * Size, Size, Size, 0.0f );
auto GeometryNode = make_intrusive<clGeometryNode>( );
GeometryNode->SetVertexAttribs( VA );
m_Boids.back()->Add( GeometryNode );
一旦几何节点初始化,我们将其添加到 m_Root
:
m_Root->Add( m_Boids.back() );
}
Update();
}
在每一帧中,我们计算与鸟群根节点关联的每个 clSceneNode
的变换。变换包括鸟群位置的*移,然后绕垂直 Z
轴旋转:
void Update()
{
for ( size_t i = 0; i != m_Boids.size(); i++ )
{
float Angle = m_Swarm->m_Boids[i]->m_Angle;
mat4 T = mat4::GetTranslateMatrix( vec3( m_Swarm->m_Boids[i]->m_Pos ) );
mat4 R = mat4::GetRotateMatrixAxis( Angle,vec3( 0, 0, 1 ) );
m_Boids[i]->SetLocalTransform( R * T );
}
}
clPtr<clSceneNode> GetRootNode() const { return m_Root; }
};
所有其他场景管理代码与之前章节的类似:
鸟群演示
1_Boids
中的演示代码混合使用了 GoTo 和 Flocking 行为,使一群鸟群追逐用户指定的目标,同时创建类似群体移动的错觉。
在这里,我们不讨论应用程序的整个源代码,只强调最重要的部分。演示的初始化从创建填充有随机位置鸟群的 clSwarm
开始:
auto Swarm = make_intrusive<clSwarm>();
Swarm->GenerateRandom( 10 );
我们为所有鸟群个体设置相同的控制器。控制器本身是 g_Behaviour
对象中的 clFlockingBehaviour
和 clGoToBehavior
的混合体:
auto MixedControl = make_intrusive<clMixedBehaviour>();
MixedControl->AddBehaviour(0.5f, make_intrusive<clFlockingBehaviour>());
MixedControl->AddBehaviour(0.5f, g_Behaviour);
Swarm->SetSingleBehaviour(MixedControl);
g_Behaviour
实例保存目标的坐标,最初设置为 (1.0, 1.0)
:
g_Behaviour->m_TargetRadius = 0.1f;
g_Behaviour->m_Target = vec2( 1.0f );
g_Behaviour->m_PosGain = 0.1f;
在渲染循环的每一帧中,都使用局部的 clSwarmRenderer
对象:
clSwarmRenderer SwarmRenderer( Swarm );
该演示使用触摸输入来改变目标的坐标。当发生触摸时,我们将穿过触摸点的线与鸟群所在的*面相交。这个交点被用作新的目标点:
void OnTouch( int X, int Y, bool Touch )
{
g_MouseState.FPos = g_Window->GetNormalizedPoint( X, Y );
g_MouseState.FPressed = Touch;
if ( !Touch )
{
一旦我们知道触摸已经结束,我们使用透视和视图矩阵将 2D 鼠标坐标反投到世界空间中:
vec3 Pos = Math::UnProjectPoint( vec3( g_MouseState.FPos ), Math::Perspective( 45.0f, g_Window->GetAspect(), 0.4f, 2000.0f ), g_Camera.GetViewMatrix() );
使用摄像机视图矩阵,我们计算旋转和*移,并使用这些值将鼠标位置的射线与 Z=0
*面相交:
mat4 CamRotation;
vec3 CamPosition;
DecomposeCameraTransformation( g_Camera.GetViewMatrix(), CamPosition, CamRotation );
vec3 isect;
bool R = IntersectRayToPlane( CamPosition, Pos - CamPosition, vec3( 0, 0, 1 ), 0, isect );
一旦构建了一个 3D 交点,它就可以用作 GoTo 行为的 2D 目标:
g_Behaviour->m_Target = isect.ToVector2();
}
}
在每次迭代中,我们调用 Swarm::Update()
和 clSwarmRenderer::Update()
方法来更新单个鸟群个体的位置和速度,并将场景节点变换与新的数据同步。
现在,去运行 1_Boids
示例,亲自看看效果。
基于页面的用户界面
前几章的大部分内容已经为可移植的 C++ 应用程序奠定了基础。现在是时候向您展示如何将更多部分连接在一起了。第七章中,我们讨论了如何在 C++ 中创建一个简单的自定义用户界面以及如何响应用户输入。在这两种情况下,我们只实现了一个固定的行为,而没有解释如何在不编写意大利面条代码的情况下切换到另一个行为。本章的第一段介绍了 行为 的概念,我们现在将其应用于我们的用户界面。
我们将用户界面的单个全屏状态称为 页面。因此,应用程序的每个不同屏幕都由 clGUIPage
类表示,我们在此之后对其进行注释。
clUIPage
的三个主要方法是 Render()
、Update()
和 OnTouch()
。Render()
方法渲染包含所有子视图的完整页面。Update()
方法将视图与应用程序状态同步。OnTouch()
方法响应用户输入。clGUIPage
类是从 clUIView
派生的,因此理解这个类应该没有问题。
类包含两个字段。FFallbackPage
字段持有指向另一个页面的指针,该页面用作返回页面,例如,在 Android 上按下后退键时:
class clGUIPage: public clUIView
{
public:
按下后退键时返回的页面:
clPtr<clGUIPage> FFallbackPage;
这个页面上的 GUI 对象的非拥有指针来自:
clGUI* FGUI;
public:
clGUIPage(): FFallbackPage( nullptr ) {}
virtual ~clGUIPage() {}
virtual void Update( float DeltaTime ) {}
virtual bool OnTouch( int x, int y, bool Pressed );
virtual void Update( double Delta );
virtual void SetActive();
当 GUI 管理器切换页面时,会调用 OnActivation()
和 OnDeactivation()
方法:
virtual void OnActivation() {}
virtual void OnDeactivation() {}
public:
virtual bool OnKey( int Key, bool KeyState );
};
页面列表存储在 clGUI
类中。FActivePage
字段指示当前可见的页面。用户输入的事件会被重定向到激活页面:
class clGUI: public iObject
{
public:
clGUI(): FActivePage( NULL ), FPages() {}
virtual ~clGUI() {}
AddPage()
方法设置指向父 GUI 对象的指针,并将此页面添加到页面容器中:
void AddPage( const clPtr<clGUIPage>& P )
{
P->FGUI = this;
FPages.push_back( P );
}
SetActivePage()
方法除了实际设置页面为激活状态外,还会调用一些回调函数。如果新页面与当前激活的页面相同,则不会执行任何操作:
void SetActivePage( const clPtr<clGUIPage>& Page )
{
if ( Page == FActivePage ) { return; }
如果我们之前有一个激活的页面,我们会通知该页面切换到另一个页面:
if ( FActivePage )
{
FActivePage->OnDeactivation();
}
如果新页面不是空页面,那么它会被通知已被激活:
if ( Page )
{
Page->OnActivation();
}
FActivePage = Page;
}
正如我们之前提到的,每个事件都会被重定向到存储在 FActivePage
中的激活页面:
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;
};
OnKey()
方法的实现只用于 Windows 或 OSX。然而,如果我们把后退键视为 Esc 键的类似物,同样的逻辑也可以应用于 Android:
bool clGUIPage::OnKey( int Key, bool KeyState )
{
if ( !KeyState && Key == LK_ESCAPE )
{
如果我们有一个非空的备用页面,我们会将其设置为激活状态:
if ( FFallbackPage )
{
FGUI->SetActivePage( FFallbackPage );
return true;
}
}
return false;
}
SetActive()
的实现被放在类声明之外,因为它使用了当时未声明的 clGUI
类。这是为了从头文件中移除依赖:
void clGUIPage::SetActive()
{
FGUI->SetActivePage( this );
}
现在,我们的小型 GUI 页面机制已经完整,可以用来在实际应用程序中处理用户界面逻辑。
总结
在本章中,我们学习了如何实现对象的不同行为以及使用状态机和设计模式来实现群体逻辑。让我们继续最后一章,这样我们就可以将许多之前的示例整合到一个更大的应用程序中。
第十章:编写“小行星”游戏
我们将继续整合之前章节的材料。我们将使用前几章介绍的技术和代码片段实现一个带有 3D 图形、阴影、粒子和声音的“小行星”游戏。首先,我们将在之前的内容中增加一些新的想法,然后继续编写一个完整的游戏应用程序。我们从屏幕操纵杆开始。
创建一个屏幕上的操纵杆
屏幕上的操纵杆基于多点触控处理。两个结构体包含了一个单一操纵杆按钮和轴的描述。按钮被赋予一个索引,并在sBitmapButton
结构的FColour
字段中通过其颜色指定。当用户在屏幕上点击,且操纵杆遮罩下方的像素颜色与其中一个按钮相匹配时,clScreenJoystick
类将设置该按钮的按下标志:
struct sBitmapButton
{
ivec4 FColour;
int FIndex;
};
sBitmapAxis
结构体代表了一个游戏手柄的操纵杆,包含对应垂直和水*方向的两个轴。在游戏手柄的位图遮罩中,它以一个以FPosition
为中心,半径为FRadius
的圆形元素表示。FAxis1
和FAxis2
索引指定了哪些逻辑游戏手柄轴受到这个屏幕操纵杆的影响。
FColour
字段用于确定用户是否触摸了这个轴:
struct sBitmapAxis
{
float FRadius;
vec2 FPosition;
int FAxis1, FAxis2;
ivec4 FColour;
};
clScreenJoystick
类的声明如下:
class ScreenJoystick: public iIntrusiveCounter
{
public:
std::vector<sBitmapButton> FButtonDesc;
std::vector<sBitmapAxis> FAxisDesc;
std::vector<float> FAxisValue;
std::vector<bool> FKeyValue;
这个位图包含了一个带有按钮的颜色遮罩:
clPtr<clBitmap> FMaskBitmap;
public:
ScreenJoystick()
{}
分配按钮和轴状态数组:
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 ) );
}
}
重置操纵杆按钮和轴的状态:
void Restart()
{
memset( &FPushedAxis[0], 0, sizeof( sBitmapAxis* ) * MAX_TOUCH_CONTACTS );
memset( &FPushedButtons[0], 0, sizeof( sBitmapButton* ) * MAX_TOUCH_CONTACTS );
}
检查按钮是否被按下:
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];
}
按钮和轴的设置器以类似的方式实现:
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 >= static_cast<int>( FAxisValue.size() ) )
{ return; }
FAxisValue[AxisIdx] = Val;
}
尝试根据游戏手柄位图遮罩中找到的颜色来检测一个按钮:
sBitmapButton* GetButtonForColour( const ivec4& Colour )
{
for ( size_t k = 0 ; k < FButtonDesc.size(); k++ )
if ( FButtonDesc[k].FColour == Colour )
return &FButtonDesc[k];
return nullptr;
}
同样的逻辑也适用于轴的检测:
sBitmapAxis* GetAxisForColour( const ivec4& Colour )
{
for ( size_t k = 0 ; k < FAxisDesc.size(); k++ )
{
if ( FAxisDesc[k].FColour == Colour )
{ return &FAxisDesc[k]; }
}
return nullptr;
}
当前按下的按钮和活动的轴存储在这些成员变量中:
public:
sBitmapButton* FPushedButtons[MAX_TOUCH_CONTACTS];
sBitmapAxis* FPushedAxis[MAX_TOUCH_CONTACTS];
void ReadAxis( sBitmapAxis* Axis, const vec2& Pos )
{
if ( !Axis ) { return; }
根据轴的中心点和触摸点读取轴的值。从中心点出发的距离代表相应轴上的一个值:
float v1 = ( Axis->FPosition - Pos ).x / Axis->FRadius;
float v2 = ( Pos - Axis->FPosition ).y / Axis->FRadius;
this->SetAxisValue( Axis->FAxis1, v1 );
this->SetAxisValue( Axis->FAxis2, v2 );
}
};
多点触控处理程序以下列方式实现:
void ScreenJoystick::HandleTouch( int ContactID, const vec2& Pos, bool Pressed, eMotionFlag Flag )
{
if ( ContactID == L_MOTION_START )
{
for ( size_t i = 0; i != MAX_TOUCH_CONTACTS; i++ )
{
if ( FPushedButtons[i] )
{
this->SetKeyState( FPushedButtons[i]->FIndex, false );
FPushedButtons[i] = nullptr;
}
if ( FPushedAxis[i] )
{
this->SetAxisValue( FPushedAxis[i]->FAxis1, 0.0f );
this->SetAxisValue( FPushedAxis[i]->FAxis2, 0.0f );
FPushedAxis[i] = nullptr;
}
}
return;
}
if ( ContactID == L_MOTION_END )
{ return; }
if ( ContactID < 0 || ContactID >= MAX_TOUCH_CONTACTS )
{ return; }
清除所有之前的按下和轴的状态:
if ( Flag == L_MOTION_DOWN || Flag == L_MOTION_MOVE )
{
int x = (int)(Pos.x * (float)FMaskBitmap->GetWidth());
int y = (int)(Pos.y * (float)FMaskBitmap->GetHeight());
ivec4 Colour = FMaskBitmap->GetPixel(x, y);
sBitmapButton* Button = GetButtonForColour( Colour );
sBitmapAxis* Axis = GetAxisForColour( Colour );
if ( Button && Pressed )
{
// touchdown, set the key
int Idx = Button->FIndex;
this->SetKeyState( Idx, true );
存储按钮的初始颜色,以便稍后跟踪其移动:
FPushedButtons[ContactID] = Button;
}
if ( Axis && Pressed )
{
this->ReadAxis( Axis, Pos );
FPushedAxis[ContactID] = Axis;
}
}
}
为了演示clScreenJoystick
类的使用,我们修改了前一章中的 boids 示例。绿色的盒子表示的目标用屏幕操纵杆控制。
实现粒子系统
为了让我们的游戏看起来更加光彩照人,我们在渲染引擎中添加了另一个组件:粒子系统。粒子类似于前一章中的 boids 移动,但数量大大超过它们,并且不打算参与复杂的交互。由于单个粒子是透明的,我们需要注意渲染顺序,并在帧内所有固体对象渲染后渲染粒子。
当谈论动力学时,每个粒子都被视为一个点状对象,并以屏幕对齐的四边形进行渲染。单个粒子并非永远存在,其初始生命周期FLifeTime
和当前生存时间FTTL
被存储。sParticle
结构包含描述其运动和动力学属性的FPosition
、FVelocity
和FAcceleration
字段。除了物理属性外,FRGBA
字段包含粒子的颜色,而FSize
字段描述了其视觉大小。我们可以这样说:
struct sParticle
{
sParticle(): FPosition(),
FVelocity(),
FAcceleration(),
FLifeTime( 0.0f ),
FTTL( 0.0f ),
FRGBA( 1.0f, 1.0f, 1.0f, 1.0f ),
FSize( 0.5f )
{};
LVector3 FPosition; // current position
LVector3 FVelocity; // current velocity
LVector3 FAcceleration; // current acceleration
float FLifeTime; // total life time
float FTTL; // time to live left
LVector4 FRGBA; // overlay color
float FSize; // particle size
};
注意
为了简化我们的实现,我们以结构数组(AoS)而不是数组结构(SoA)的形式存储粒子。SoA 方法对缓存更加友好且速度更快。如果你对如何更有效地实现基于 CPU 的粒子系统感兴趣,请参考以下系列博客文章:www.bfilipek.com/2014/04/flexible-particle-system-start.html
。
clParticleSystem
类的私有部分包含一个带有 GPU 就绪粒子数据的clVertexAttribs
对象,一个sParticle
实例的容器,一个用于我们渲染系统的材质描述,以及当前活动的粒子数量:
class clParticleSystem: public iIntrusiveCounter
{
private:
clPtr<clVertexAttribs> FVertices;
std::vector<sParticle> FParticles;
sMaterial FMaterial;
int FCurrentMaxParticles;
构造函数为初始数量的粒子预分配顶点:
public:
clParticleSystem(): FCurrentMaxParticles( 100 )
{
const int VerticesPerParticle = 6;
FVertices = make_intrusive<clVertexAttribs>( VerticesPerParticle * FCurrentMaxParticles );
指定了一个特殊的材质类名。我们的渲染系统将识别这种材质,并使用适当的着色器来渲染粒子系统:
FMaterial.m_MaterialClass = "Particle";
}
virtual void AddParticle( const sParticle& Particle )
{
FParticles.push_back( Particle );
如果粒子的数量超过了顶点数组的当前容量,使用系数1.2
进行扩容。这里增长系数的最优选择取决于实验,并依赖于向粒子系统提供粒子的发射器:
if ( FCurrentMaxParticles <
static_cast<int>( FParticles.size() ) )
{
SetMaxParticles(int(FCurrentMaxParticles * 1.2f));
}
}
SetMaxParticles()
方法调整FVertices
顶点数组的大小,以至少容纳MaxParticles
:
void SetMaxParticles( int MaxParticles );
我们还需要一系列的获取成员函数来访问类的私有字段:
virtual std::vector<sParticle>& GetParticles()
{ return FParticles; }
virtual clPtr<clVertexAttribs> GetVertices() const
{ return FVertices; }
virtual const sMaterial& GetDefaultMaterial() const
{ return FMaterial; }
virtual sMaterial& GetDefaultMaterial() { return FMaterial; }
这里是所有操作发生的地方。我们将在下一页中探讨这个方法:
virtual void UpdateParticles( float DeltaSeconds );
};
SetMaxParticles()
方法可能看起来很简单,但实际上除了容器大小的调整之外,还包含一些有用的代码。为了渲染粒子,我们使用了称为 billboarding 的技术。对于每个粒子,我们创建一个由两个三角形组成的屏幕对齐的四边形。四边形角的纹理坐标是固定的,对于每个粒子,我们在SetMaxParticles()
方法中填充U
和V
值:
void clParticleSystem::SetMaxParticles( int MaxParticles )
{
FCurrentMaxParticles = MaxParticles;
首先,我们将调整FParticles
数组和FVertices
对象的大小:
const int VerticesPerParticle = 6;
FParticles.reserve( FCurrentMaxParticles );
FVertices = make_intrusive<clVertexAttribs>
( VerticesPerParticle * MaxParticles );
vec2* Vec = FVertices->FTexCoords.data();
遍历粒子并给每个顶点分配六个纹理坐标对:
for ( int i = 0; i != MaxParticles; ++i )
{
int IdxI = i * 6;
Vec[IdxI + 0] = vec2( 0.0f, 0.0f );
Vec[IdxI + 1] = vec2( 1.0f, 0.0f );
Vec[IdxI + 2] = vec2( 1.0f, 1.0f );
Vec[IdxI + 3] = vec2( 0.0f, 0.0f );
Vec[IdxI + 4] = vec2( 1.0f, 1.0f );
Vec[IdxI + 5] = vec2( 0.0f, 1.0f );
}
}
我们每帧都在FVertices
和FParticles
字段之间同步粒子坐标、生命周期和颜色。UpdateParticles()
方法为每个粒子计算新的位置和速度,然后更新FVertices
对象的各个组件:
void clParticleSystem::UpdateParticles( float DeltaSeconds )
{
vec3* Vec = FVertices->FVertices.data();
vec3* Norm = FVertices->FNormals.data();
vec4* RGB = FVertices->FColors.data();
size_t NumParticles = FParticles.size();
for ( size_t i = 0; i != NumParticles; ++i )
{
sParticle& P = FParticles[i];
P.FTTL -= DeltaSeconds;
如果粒子的生存时间小于零,我们用数组中的最后一个粒子替换它,这样我们可以有效地将死粒子从容器中弹出:
if ( P.FTTL < 0 )
{
P = FParticles.back();
FParticles.pop_back();
NumParticles--;
i--;
continue;
}
使用牛顿物理学和显式欧拉积分器,正如我们在上一章对鸟群所做的那样,我们为每个粒子重新计算新的速度和位置:
P.FVelocity += P.FAcceleration * DeltaSeconds;
P.FPosition += P.FVelocity * DeltaSeconds;
粒子的生存时间、总生命时间和大小被打包到一个vec3
变量中,这样它们就可以存储在顶点数组中:
LVector3 TTL = LVector3( P.FTTL, P.FLifeTime, P.FSize );
为了简化公式,我们归一化粒子的生命周期,并在我们的颜色计算中使用它:
float NormalizedTime = (P.FLifeTime-P.FTTL) / P.FLifeTime;
根据归一化时间,我们计算当前粒子的颜色。GetParticleBrightness()
函数的描述如下:
vec4 Color = P.FRGBA * GetParticleBrightness( NormalizedTime );
由于每个粒子由两个三角形表示,我们在顶点数组中为连续的六个元素分配相同的值:
size_t IdxI = i * 6;
for ( int j = 0; j < 6; j++ )
{
Vec [IdxI + j] = P.FPosition;
Norm[IdxI + j] = TTL;
RGB [IdxI + j] = Color;
}
}
更新每个粒子后,我们调整顶点数组中要渲染的顶点数量,以匹配当前活动的粒子数:
FVertices->SetActiveVertexCount ( 6 * static_cast<int>( FParticles.size() ) );
}
GetParticleBrightness()
函数计算了一个梯形函数,对于从0.1
到0.9
的参数值,该函数的值为1.0
。从视觉上讲,这意味着在粒子生命周期的开始,粒子从零渐变到完全可见,然后以恒定强度发光,然后线性衰减到零:
inline float GetParticleBrightness( float NormalizedTime )
{
const float Cutoff_Lo = 0.1f;
const float Cutoff_Hi = 0.9f;
if ( NormalizedTime < Cutoff_Lo )
{
return NormalizedTime / Cutoff_Lo;
}
if ( NormalizedTime > Cutoff_Hi )
{
return 1.0f - ( ( NormalizedTime - Cutoff_Hi ) / ( 1.0f - Cutoff_Hi ) );
}
return 1.0f;
}
在这一点上,我们只定义了持有粒子实例的类。为了将这些新对象集成到我们的渲染系统中,我们必须定义一种新的场景图节点,即clParticleSystemNode
节点。在我们这样做之前,应该先讲述一下粒子的发射方式。
我们介绍了iParticleEmitter
接口,该接口声明了一个纯虚方法EmitParticles()
,该方法有两个参数。DeltaTime
参数用于更新时间计数器并在PS
粒子系统中计算新粒子的位置:
class iParticleEmitter: public iIntrusiveCounter
{
public:
iParticleEmitter():
FColorMin( 0 ), FColorMax( 1 ),
FSizeMin( 0.5f ), FSizeMax( 1.0f ),
FVelMin( 0 ), FVelMax( 0 ),
FMaxParticles( 1000 ),
FEmissionRate( 100.0f ),
FLifetimeMin( 1.0f ), FLifetimeMax( 60.0f ),
FInvEmissionRate( 1.0f / FEmissionRate ),
FAccumulatedTime( 0.0f )
{}
virtual void EmitParticles( const clPtr<clParticleSystem>& PS, float DeltaTime ) const = 0;
该类的字段定义了粒子每个参数的允许范围。颜色、大小、速度和生命周期的限制由带有Min
和Max
后缀的变量给出。FEmissionRate
定义了我们每秒发射的粒子数,而FMaxParticles
给出了粒子数量的上限。FAccumulatedTime
字段包含了自上次粒子系统更新以来大约经过的时间:
public:
vec4 FColorMin, FColorMax;
float FSizeMin, FSizeMax;
vec3 FVelMin, FVelMax;
size_t FMaxParticles;
float FEmissionRate;
float FLifetimeMin, FLifetimeMax;
protected:
float FInvEmissionRate;
mutable float FAccumulatedTime;
};
EmitParticles()
方法在两个子类中被重写。第一个子类是clParticleEmitter_Box
,它在一个轴向对齐的盒子区域发射粒子:
class clParticleEmitter_Box: public iParticleEmitter
{
public:
clParticleEmitter_Box(): FPosMin( 0 ), FPosMax( 1 ) {}
virtual void EmitParticles( const clPtr<clParticleSystem>& PS, float DeltaTime ) const override
{
FAccumulatedTime += DeltaTime;
下面的循环一次发射一个所需数量的粒子。位置、速度、颜色、生存时间和大小都用均匀随机变量填充:
while ( FAccumulatedTime > 1.0f / FEmissionRate && PS->GetParticles().size() < FMaxParticles )
{
FAccumulatedTime -= 1.0f / FEmissionRate;
sParticle P;
P.FPosition = Math::RandomVector3InRange( FPosMin, FPosMax );
P.FVelocity = Math::RandomVector3InRange( FVelMin, FVelMax );
P.FAcceleration = LVector3( 0.0f );
P.FTTL = Math::RandomInRange( FLifetimeMin, FLifetimeMax );
P.FLifeTime = P.FTTL;
P.FRGBA = Math::RandomVector4InRange( FColorMin, FColorMax );
P.FRGBA.w = 1.0f;
P.FSize = Math::RandomInRange(FSizeMin, FSizeMax);
PS->AddParticle( P );
}
}
public:
vec3 FPosMin, FPosMax;
};
这是可能最简单的发射器之一。
在游戏中使用粒子系统
我们的游戏还需要一个看起来不错爆炸效果。在clParticleEmitter_Explosion
类中实现了以燃烧方式发射粒子的功能:
class clParticleEmitter_Explosion: public iParticleEmitter
{
public:
clParticleEmitter_Explosion()
: FEmitted( false ), FCenter( 0.0f )
, FRadialVelocityMin( 0.1f ), FRadialVelocityMax( 1.0f )
, FAcceleration( 0.0f )
{}
virtual void EmitParticles( const clPtr<clParticleSystem>& PS,
float DeltaTime ) const override;
public:
mutable bool FEmitted;
vec3 FCenter;
float FRadialVelocityMin, FRadialVelocityMax;
vec3 FAcceleration;
};
构造函数将FEmitted
字段设置为false
。在第一次调用EmitParticles()
时,此字段被设置为true
,并发射固定数量的主粒子:
void clParticleEmitter_Explosion::EmitParticles( const clPtr<clParticleSystem>& PS, float DeltaTime ) const
{
auto& Particles = PS->GetParticles();
size_t OriginalSize = Particles.size();
爆炸效果只添加一次大量粒子,但在每次顺序调用EmitParticles()
时,会创建次级粒子,形成跟随主粒子路径的轨迹。对于现有粒子集合中的每个实体,都会创建一个附加粒子,以使粒子总数保持在FMaxParticles
变量设定的预算内:
for ( size_t i = 0; i != OriginalSize; i++ )
{
if ( Particles[i].FRGBA.w > 0.99f && Particles.size() < FMaxParticles )
{
sParticle P;
P.FPosition = Particles[i].FPosition;
P.FVelocity = Particles[i].FVelocity * Math::RandomVector3InRange( vec3(0.1f), vec3(1.0f) );
P.FAcceleration = FAcceleration;
P.FTTL = Particles[i].FTTL * 0.5f;
P.FLifeTime = P.FTTL;
P.FRGBA = Particles[i].FRGBA * Math::RandomVector4InRange( vec4(0.5f), vec4(0.9f) );
P.FRGBA.w = 0.95f;
P.FSize = Particles[i].FSize * Math::RandomInRange(0.1f, 0.9f);
PS->AddParticle( P );
}
}
一旦我们创建了爆炸效果,这个发射器将不再工作:
if ( FEmitted ) return;
FEmitted = true;
下面的循环创建了一个方向在球体上均匀分布的粒子喷雾:
for ( size_t i = 0; i != FEmissionRate; i++ )
{
sParticle P;
使用两个均匀分布的随机变量作为角度,我们计算了均匀的随机方向:
float Theta = Math::RandomInRange( 0.0f, Math::TWOPI );
float Phi = Math::RandomInRange( 0.0f, Math::TWOPI );
float SinTheta = sin(Theta);
float x = SinTheta * cos(Phi);
float y = SinTheta * sin(Phi);
float z = cos(Theta);
每个粒子从爆炸中心开始,速度与前面代码中计算出的随机方向相乘的随机大小一致:
P.FPosition = FCenter;
P.FVelocity = vec3( x, y, z ).GetNormalized() * Math::RandomInRange( FRadialVelocityMin, FRadialVelocityMax );
P.FAcceleration = FAcceleration;
生存时间、颜色和大小字段填充了均匀的随机值:
P.FTTL = Math::RandomInRange( FLifetimeMin, FLifetimeMax );
P.FLifeTime = P.FTTL;
P.FRGBA = Math::RandomVector4InRange( FColorMin, FColorMax );
P.FRGBA.w = 1.0f;
P.FSize = Math::RandomInRange( FSizeMin, FSizeMax );
PS->AddParticle( P );
}
}
在场景图中使用粒子系统
现在,我们准备声明并定义clParticleSystemNode
类,它拥有一个clParticleSystem
对象,带有粒子几何的clGeometryNode
,以及iParticleEmitter
对象的容器:
class clParticleSystemNode: public clMaterialNode
{
private:
std::vector< clPtr<iParticleEmitter> > m_Emitters;
clPtr<clParticleSystem> m_Particles;
clPtr<clGeometryNode> m_ParticlesNode;
public:
clParticleSystemNode();
virtual void UpdateParticles( float DeltaSeconds );
virtual clPtr<clParticleSystem> GetParticleSystem() const
{ return m_Particles; };
以下五个方法提供了对私有粒子发射器容器的访问权限:
virtual void AddEmitter( const clPtr<iParticleEmitter>& E )
{ m_Emitters.push_back(E); }
virtual void RemoveEmitter( const clPtr<iParticleEmitter>& E )
{
m_Emitters.erase( std::remove( m_Emitters.begin(), m_Emitters.end(), E ), m_Emitters.end() );
}
virtual clPtr<iParticleEmitter> GetEmitter( size_t i ) const
{ return m_Emitters[i]; }
virtual void SetEmitter( size_t i, const clPtr<iParticleEmitter> E )
{ m_Emitters[i] = E; }
virtual size_t GetTotalEmitters() const
{ return m_Emitters.size(); }
};
构造函数实例化了一个粒子系统以及所有必要的场景节点:
clParticleSystemNode::clParticleSystemNode()
{
m_Particles = make_intrusive<clParticleSystem>();
size_t MaxParticles = 20000;
for ( const auto& i : m_Emitters )
{
if (i->FMaxParticles > MaxParticles)
MaxParticles = i->FMaxParticles;
}
m_Particles->SetMaxParticles( static_cast<int>(MaxParticles) );
创建一个几何节点以存储粒子顶点:
m_ParticlesNode = make_intrusive<clGeometryNode>();
m_ParticlesNode->SetVertexAttribs(m_Particles->GetVertices());
this->Add( m_ParticlesNode );
从粒子系统中获取材质,并将其应用到场景节点上:
this->SetMaterial( m_Particles->GetDefaultMaterial() );
UpdateParticles( 0.0f );
}
clParticleSystemNode::UpdateParticles()
方法调用所有发射器,然后为m_Particles
调用clParticleSystem::UpdateParticles()
,并最终使用clGLVertexAray::CommitChanges()
调用将新的粒子顶点数据发送到渲染 API:
void clParticleSystemNode::UpdateParticles( float DeltaSeconds )
{
for ( const auto& i : m_Emitters )
{
i->EmitParticles( m_Particles, DeltaSeconds );
}
m_Particles->UpdateParticles( DeltaSeconds );
m_ParticlesNode->GetVA()->CommitChanges();
}
包含粒子属性的clVertexAttribs
的渲染需要编写新的着色器。由于粒子代表了一种新的几何类型,我们扩展了我们的clMaterialSystem
类,使其能够处理粒子材质:
class clParticleMaterialSystem: public clMaterialSystem
{
public:
clParticleMaterialSystem()
{
m_ParticleShaderPrograms[ ePass_Ambient ] = make_intrusive<clGLSLShaderProgram>( g_vShaderParticleStr, g_fShaderAmbientParticleStr );
m_ParticleShaderPrograms[ ePass_Light ] = make_intrusive<clGLSLShaderProgram>( g_vShaderParticleStr, g_fShaderLightParticleStr );
m_ParticleShaderPrograms[ ePass_Shadow ] = make_intrusive<clGLSLShaderProgram>( g_vShaderShadowParticleStr, g_fShaderShadowParticleStr );
}
GetShaderProgramForPass()
成员函数检查材质类是否为Particle,并从一组新的粒子着色器程序中选择一个着色器程序。否则,它会回退到旧的clMaterialSystem
实现:
virtual clPtr<clGLSLShaderProgram> GetShaderProgramForPass( ePass Pass, const sMaterial& Mtl ) override
{
if ( Mtl.m_MaterialClass == "Particle" )
return m_ParticleShaderPrograms[ Pass ];
return clMaterialSystem::GetShaderProgramForPass( Pass, Mtl );
}
此类中唯一的新字段是一个映射,用于保存每个通道的新编译着色器程序:
private:
std::map<ePass, clPtr<clGLSLShaderProgram>> m_ParticleShaderPrograms;
};
以下是渲染粒子所需的所有新着色器的源代码。顶点着色器在所有渲染通道之间共享,并执行公告板处理;这会将粒子对准到相机:
static const char g_vShaderParticleStr[] = R"(
uniform mat4 in_ModelViewProjectionMatrix;
uniform mat4 in_NormalMatrix;
uniform mat4 in_ModelMatrix;
uniform mat4 in_ModelViewMatrix;
uniform mat4 in_ShadowMatrix;
in vec4 in_Vertex;
in vec2 in_TexCoord;
in vec3 in_Normal;
in vec4 in_Color;
out vec2 v_Coords;
out vec3 v_Normal;
out vec3 v_WorldNormal;
out vec4 v_ProjectedVertex;
out vec4 v_ShadowMapCoord;
out vec3 v_Params;
out vec4 v_Color;
与默认材质相同的投影变换缩放偏置:
mat4 GetProjScaleBiasMat()
{
// transform from -1..1 to 0..1
return mat4(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 0.5, 0.0,
0.5, 0.5, 0.5, 1.0 );
}
void main()
{
粒子应该被定向,使它们始终面向相机。让我们构建一个参考框架,其中向量X
和Y
与屏幕*行:
vec3 X = vec3( in_ModelViewMatrix[0][0], in_ModelViewMatrix[1][0], in_ModelViewMatrix[2][0] );
vec3 Y = vec3(in_ModelViewMatrix[0][1], in_ModelViewMatrix[1][1], in_ModelViewMatrix[2][1] );
获取存储在法线 Z 分量中的粒子大小:
float SizeX = in_Normal.z;
float SizeY = in_Normal.z;
使用纹理坐标来计算粒子中心偏移量:
vec3 XOfs = ( 2.0 * (in_TexCoord.x-0.5) * SizeX ) * X;
vec3 YOfs = ( 2.0 * (in_TexCoord.y-0.5) * SizeY ) * Y;
vec3 Position = in_Vertex.xyz + XOfs + YOfs;
使用模型视图投影矩阵变换顶点位置:
vec4 TransformedPos = in_ModelViewProjectionMatrix * vec4( Position, 1.0 );
gl_Position = TransformedPos;
传递所有其他变量:
v_Coords = in_TexCoord.xy;
v_Normal = mat3(in_NormalMatrix) * in_Normal;
v_WorldNormal = ( in_ModelMatrix * vec4( in_Normal, 0.0 ) ).xyz;
v_ProjectedVertex = GetProjScaleBiasMat() * in_ModelViewProjectionMatrix * vec4(Position, 1.0);
v_ShadowMapCoord = in_ShadowMatrix * in_ModelMatrix * vec4(Position, 1.0);
v_Params = in_Normal;
v_Color = in_Color;
}
)";
片段着色器更为多样,需要不同的着色器来处理环境光、阴影和光照传递,以正确渲染粒子。以下是环境传递的粒子片段着色器:
static const char g_fShaderAmbientParticleStr[] = R"(
in vec2 v_Coords;
in vec3 v_Normal;
in vec3 v_WorldNormal;
v_Params
的值来自clParticleSystem::UpdateParticles()
函数,其中 TTL、生命周期和大小打包在一起:
in vec3 v_Params;
in vec4 v_Color;
out vec4 out_FragColor;
uniform vec4 u_AmbientColor;
uniform vec4 u_DiffuseColor;
void main()
{
vec4 Color = v_Color * u_AmbientColor;
float NormalizedTime = (v_Params.y-v_Params.x) / v_Params.y;
根据到粒子中心的距离计算透明度。这样可以得到没有使用任何纹理的圆润粒子:
float Falloff = 1.0 - 2.0 * length(v_Coords-vec2(0.5, 0.5));
if ( NormalizedTime < 0.1 )
{
Falloff *= NormalizedTime / 0.1;
}
else if ( NormalizedTime > 0.5 )
{
Falloff *= 1.0 - (NormalizedTime-0.5) / 0.5;
}
Color.w = Falloff;
out_FragColor = Color;
}
)";
光照传递的片段着色器如下所示。它只是丢弃了片段,粒子不会对光源产生反应:
static const char g_fShaderLightParticleStr[] = R"(
in vec2 v_Coords;
in vec3 v_Normal;
in vec3 v_WorldNormal;
in vec4 v_ProjectedVertex;
in vec4 v_ShadowMapCoord;
in vec3 v_Params;
in vec4 v_Color;
out vec4 out_FragColor;
void main()
{
discard;
}
)";
阴影映射生成传递可以用以下片段着色器处理。为每个粒子创建一个圆形阴影:
static const char g_fShaderShadowParticleStr[] = R"(
in vec2 v_Coords;
in vec3 v_Params; /* TTL, LifeTime, Size */
out vec4 out_FragColor;
void main()
{
float NormalizedTime = (v_Params.y-v_Params.x) / v_Params.y;
在粒子生命周期的一半内,阴影会增大,之后缩小到零:
float Falloff = ( NormalizedTime < 0.5 ) ? NormalizedTime : 1.0-NormalizedTime;
if ( length(v_Coords-vec2(0.5, 0.5)) > 0.5 * Falloff ) discard;
out_FragColor = vec4( 1.0 );
}
)";
在1_Particles
示例中可以找到粒子渲染的演示。以下是运行应用程序的截图:
初始阶段,我们创建一个空的粒子系统节点,并将其传递给GenerateExplosion()
函数,该函数为粒子系统添加了另一个爆炸效果。以下是实现方式:
void GenerateExplosion( const clPtr<clParticleSystemNode>& ParticleNode, const vec3& Pos )
{
演示旨在在 Android 设备上运行;不要生成太多粒子:
if ( ParticleNode->GetParticleSystem()->GetParticles() .size() > 8000 ) return;
三种不同爆炸类型的调色板包括蓝色、红色和绿色:
const vec4 Pal[] = {
vec4(0.2f, 0.30f, 0.8f, 1.0f),
vec4(0.7f, 0.25f, 0.3f, 1.0f),
vec4(0.1f, 0.80f, 0.2f, 1.0f)
};
随机选择一种染色:
vec4 Color = Pal[ Math::RandomInRange(0, 3) ];
创建并设置发射器对象。强烈建议您玩转这些参数:
auto Emitter = make_intrusive<clParticleEmitter_Explosion>();
Emitter->FCenter = Pos;
Emitter->FSizeMin = 0.02f;
Emitter->FSizeMax = 0.05f;
Emitter->FLifetimeMin = 0.1f;
Emitter->FLifetimeMax = 1.0f;
Emitter->FMaxParticles = 10000;
Emitter->FEmissionRate = 300;
Emitter->FRadialVelocityMin = 1.0f;
Emitter->FRadialVelocityMax = 2.0f;
Emitter->FColorMin = Color;
Emitter->FColorMax = Color;
Emitter->FAcceleration = vec3( 0.0f, 0.0f, -3.0f );
ParticleNode->AddEmitter( Emitter );
}
该函数从主循环中调用:
while( g_Window && g_Window->HandleInput() )
{
double NextSeconds = Env_GetSeconds();
float DeltaTime = static_cast<float>( NextSeconds - Seconds );
Seconds = NextSeconds;
float SlowMotionCoef = 0.5f;
if ( g_UpdateParticles )
ParticleNode->UpdateParticles( SlowMotionCoef * DeltaTime );
投掷骰子以决定是否应添加另一个爆炸效果:
bool Add = Math::RandomInRange( 0, 100 ) > 50.0f;
如果粒子系统不包含活跃的粒子,则始终添加新的爆炸效果:
if ( !ParticleNode->GetParticleSystem()-> GetParticles().size() || Add )
{
GenerateExplosion( ParticleNode, Math::RandomVector3InRange(vec3(-1), vec3(+1)) );
}
OnDrawFrame();
g_Window->Swap();
}
尝试为 Android 构建此演示并在您的设备上运行。
小行星游戏
现在,我们已经准备好处理实际的游戏。本质上,游戏包含了许多前例拼接在一起,以共同运行并实现应用程序的不同方面。定义游戏逻辑的“胶水”在clGameManager
类中,该类在Game.cpp
和Game.h
中定义。实际的类似鸟类的实体在Actors.cpp
和Actors.h
中实现。让我们从基类iActor
开始:
class iActor: public iIntrusiveCounter
{
public:
iActor():
m_Pos(0),
m_Vel(0),
m_Accel(0)
{}
与所有前例的主要区别在于,这个游戏框架中没有Render()
方法。相反,所有实体都应该知道如何附加到场景图以及从中分离。这些方法在子类中被重写,并且因不同类型的参与者而异:
virtual void AttachToScene( const clPtr<clSceneNode>& Scene ) = 0;
virtual void DetachFromScene( const clPtr<clSceneNode>& Scene ) = 0;
一些代码在所有子类之间共享:
virtual void Update( float dt )
{
m_Vel += m_Accel * dt;
m_Pos += m_Vel * dt;
}
virtual float GetRadius() const
{
return 0.1f;
}
public:
vec3 m_Pos;
vec3 m_Vel;
vec3 m_Accel;
};
小行星游戏实体是clAsteroid
类的实例,非常简单:
class clAsteroid: public iActor
{
public:
clAsteroid()
: m_Angle( Math::RandomInRange( 0.0f, 1.0f ) )
{}
virtual void AttachToScene( const clPtr<clSceneNode>& Scene ) override;
virtual void DetachFromScene( const clPtr<clSceneNode>& Scene ) override;
virtual void Update( float dt ) override;
private:
clPtr<clMaterialNode> m_Node;
float m_Angle;
};
实现几乎微不足道。更新位置并将其限制在游戏关卡的大小内:
void clAsteroid::Update( float dt )
{
iActor::Update( dt );
m_Angle += dt;
m_Pos = g_Game->ClampToLevel( m_Pos );
mat4 ScaleFix = mat4::GetScaleMatrix( vec3(0.002f ) );
mat4 Pos = mat4::GetTranslateMatrix( m_Pos );
小行星总是围绕(1,1,1)
轴旋转:
mat4 Rot = mat4::GetRotateMatrixAxis( m_Angle, vec3( 1, 1, 1 ) );
if ( m_Node )
m_Node->SetLocalTransform( ScaleFix * Rot * Pos );
}
附加到场景主要是加载一个适当的 3D 模型的.obj
文件并设置材质。黄色会很好看:
void clAsteroid::AttachToScene( const clPtr<clSceneNode>& Scene )
{
if ( !m_Node )
{
auto Geometry = LoadOBJSceneNode( g_FS->CreateReader( "deimos.obj" ) );
sMaterial Material;
Material.m_Ambient = vec4( 0.5f, 0.5f, 0.0f, 1.0f );
Material.m_Diffuse = vec4( 0.5f, 0.5f, 0.0f, 1.0f );
m_Node = make_intrusive<clMaterialNode>();
m_Node->SetMaterial( Material );
m_Node->Add( Geometry );
}
Scene->Add( m_Node );
}
从场景中分离很简单:
void clAsteroid::DetachFromScene( const clPtr<clSceneNode>& Scene )
{
Scene->Remove( m_Node );
}
clRocket
类表示从太空船发射的火箭。除了Update()
方法之外,一切都与clAsteroid
的实现相似:
void clRocket::Update(float dt)
{
iActor::Update( dt );
mat4 Pos = mat4::GetTranslateMatrix( m_Pos );
if ( m_Node ) m_Node->SetLocalTransform( Pos );
如果火箭离开了关卡区域,就销毁它:
if ( !g_Game->IsInsideLevel( m_Pos ) )
{
g_Game->Kill( this );
}
}
爆炸在clExplosion
类中实现。clExplosion::AttachToScene()
方法创建了一个与GenerateExplosion()
中相似的发射器的粒子系统节点。那里没有什么有趣的。然而,Update()
方法略有不同:
void clExplosion::Update( float dt )
{
iActor::Update( dt );
mat4 ScaleFix = mat4::GetScaleMatrix( vec3(1.0f ) );
mat4 Pos = mat4::GetTranslateMatrix(m_Pos);
if ( m_Node )
{
粒子系统节点需要更新。使用系数让粒子移动得更慢:
const float SlowMotionCoef = 0.1f;
m_Node->SetLocalTransform( ScaleFix * Pos );
m_Node->UpdateParticles( SlowMotionCoef * dt );
}
当所有粒子消失后,销毁爆炸:
if ( !m_Node->GetParticleSystem()->GetParticles().size() )
{
g_Game->Kill( this );
}
}
最后但同样重要的是,clSpaceShip
类表示一个可由玩家控制的实体。同样,最有趣的部分是处理用户控制的Update()
方法:
void clSpaceShip::Update( float dt )
{
iActor::Update( dt );
询问游戏管理器是否按下了任何控制键:
if ( g_Game->IsKeyPressed( SDLK_LEFT ) )
{
m_Angle += dt;
}
if ( g_Game->IsKeyPressed( SDLK_RIGHT ) )
{
m_Angle -= dt;
}
bool Accel = g_Game->IsKeyPressed( SDLK_UP );
bool Decel = g_Game->IsKeyPressed( SDLK_DOWN );
m_Accel = vec3( 0.0f );
if ( Accel )
{
m_Accel = GetDirection();
}
if ( Decel )
{
m_Accel += -GetDirection();
}
if ( g_Game->IsKeyPressed( SDLK_SPACE ) )
{
Fire();
}
让船在关卡的相对两侧之间进行跃迁:
m_Pos = g_Game->ClampToLevel( m_Pos );
我们不希望它移动得太快;这里实现了速度衰减和限制。
m_Vel *= 0.99f;
const float MaxVel = 1.1f;
if ( m_Vel.Length() > MaxVel ) m_Vel = ( m_Vel / m_Vel.Length() ) * MaxVel;
使用时间计数器限制发射速率:
m_FireTime -= dt;
if ( m_FireTime < 0 ) m_FireTime = 0.0f;
缩放和旋转 3D 模型以匹配所需的大小和方向:
mat4 ScaleFix = mat4::GetScaleMatrix( vec3(0.1f ) );
mat4 RotFix = mat4::GetRotateMatrixAxis( 90.0f * Math::DTOR, vec3(0,0,1) );
mat4 Pos = mat4::GetTranslateMatrix(m_Pos);
mat4 Rot = mat4::GetRotateMatrixAxis( m_Angle, vec3(0,0,1) );
应用累积变换:
if ( m_Node ) m_Node->SetLocalTransform( ScaleFix * RotFix * Rot * Pos );
}
Fire
方法所做的正是它看起来要做的事。它发射火箭并维持发射速率:
void clSpaceShip::Fire()
{
if ( m_FireTime > 0.0f ) return;
尝试改变武器冷却时间。一秒是默认值:
const float FireCooldown = 1.0f;
m_FireTime = FireCooldown;
游戏管理器添加实际的火箭实体:
g_Game->FireRocket( m_Pos, m_Vel * Math::RandomInRange( 1.1f, 1.5f ) + GetDirection() );
}
这些都是游戏中存在的实体。让我们快速浏览一下统治它们的clGameManager
类:
class clGameManager: public iIntrusiveCounter
{
public:
clGameManager();
更新所有对象的状态并计算碰撞:
virtual void GenerateTicks();
使用渲染技术绘制游戏世界:
virtual void Render();
virtual void OnKey( int Key, bool Pressed );
clPtr<clSceneNode> GetSceneRoot() const { return m_Scene; };
virtual bool IsKeyPressed( int Code );
有两个函数用于创建新实体;它们在clSpaceShip
和CheckCollisions()
中使用:
virtual void FireRocket( const vec3& Pos, const vec3& Vel );
virtual void AddExplosion( const vec3& Pos, const vec3& Dir );
一些高级数学函数用于处理实体位置:
virtual bool IsInsideLevel( const vec3& Pos );
virtual vec3 ClampToLevel( const vec3& Pos );
销毁游戏演员,可能是小行星、爆炸或火箭。在我们的游戏中,太空船永存。Kill()
方法不会立即移除演员。相反,它会将演员添加到一个容器中,稍后在PerformExecution()
方法中处理:
virtual void Kill( iActor* Actor );
名字说明了一切。以“即发即忘”的方式播放音频文件:
virtual void PlayAudioFile( const std::string& FileName );
private:
void PerformExecution();
void SpawnRandomAsteroids( size_t N );
void CheckCollisions();
private:
clPtr<clSceneNode> m_Scene;
clPtr<clSpaceShip> m_SpaceShip;
std::vector< clPtr<clAsteroid> > m_Asteroids;
std::vector< clPtr<clRocket> > m_Rockets;
std::vector< clPtr<clExplosion> > m_Explosions;
std::unordered_map<int, bool> m_Keys;
vec3 m_LevelMin;
vec3 m_LevelMax;
std::vector< iActor* > m_DeathRow;
std::vector< clPtr<clAudioSource> > m_Sounds;
// file name -> blob
std::map< std::string, clPtr<clBlob> > m_SoundFiles;
};
游戏逻辑的大中央调度位于GenerateTicks()
方法中:
void clGameManager::GenerateTicks()
{
const float DeltaSeconds = 0.05f;
更新一切,检查碰撞,并移除死亡对象:
for ( const auto& i: m_Asteroids ) i->Update( DeltaSeconds );
for ( const auto& i: m_Rockets ) i->Update( DeltaSeconds );
for ( const auto& i: m_Explosions ) i->Update( DeltaSeconds );
m_SpaceShip->Update( DeltaSeconds );
CheckCollisions();
PerformExecution();
for ( size_t i = 0; i != m_Sounds.size(); i++ )
{
if ( !m_Sounds[i]->IsPlaying() )
{
逐一移除已停止的音频源:
g_Audio.UnRegisterSource( m_Sounds[i].GetInternalPtr() );
m_Sounds[i]->Stop();
m_Sounds[i] = m_Sounds.back();
m_Sounds.pop_back();
break;
}
}
}
碰撞检查是用一个简单的O(n²)
算法完成的:
void clGameManager::CheckCollisions()
{
for ( size_t i = 0; i != m_Rockets.size(); i++ )
{
for ( size_t j = 0; j != m_Asteroids.size(); j++ )
{
vec3 PosR = m_Rockets[i]->m_Pos;
vec3 PosA = m_Asteroids[j]->m_Pos;
float R = m_Asteroids[j]->GetRadius();
如果火箭足够接*小行星,就销毁两者并产生巨大的爆炸:
if ( (PosR-PosA).Length() < R )
{
this->Kill(m_Rockets[i].GetInternalPtr());
this->Kill(m_Asteroids[j].GetInternalPtr());
AddExplosion( m_Asteroids[j]->m_Pos, m_Rockets[i]->m_Vel );
}
}
}
}
执行很快,但需要一些 C++模板魔法:
void clGameManager::PerformExecution()
{
for ( const auto& i : m_DeathRow )
{
i->DetachFromScene( m_Scene );
Remove( m_Asteroids, i );
Remove( m_Explosions, i );
Remove( m_Rockets, i );
}
m_DeathRow.clear();
}
这是处理异构实体容器的模板代码:
template <typename Container, typename Entity>
void Remove( Container& c, Entity e )
{
auto iter = std::remove_if( c.begin(), c.end(), e
{
return Ent == e;
} );
c.erase( iter, c.end() );
}
如果你是一个 C++14 的粉丝,你绝对可以用const auto&
替换 lambda 参数中的const typename Container::value_type&
,但我们的 Visual Studio 2013 拒绝编译新代码。
这里未提及的其他功能可以在1_Asteroids
示例中找到。构建并运行代码,效果应如下所示:
总结
在本章中,我们总结了书中展示的许多技巧,并使用 Android NDK 实现了一个可移植的游戏应用。我们所有 C++代码调试的核心在于能够不变地在桌面计算机上运行我们的游戏。这种方法在调试大型 C++移动应用以及在将这些应用整合到新内容时提供了极大的便利和更快的迭代速度。此外,专业的移动开发永远不会只关注一个*台。通过这些开发实践,你可以编写在许多移动*台(包括 Android 和 iOS)上运行的 C++代码。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· spring官宣接入deepseek,真的太香了~
2022-05-22 PyTorch 1.0 中文文档:Windows FAQ
2021-05-22 ApacheCN 编程/大数据/数据科学/人工智能学习资源 2019.4