OpenGL 编程/Android GLUT 包装器
我们的包装器:制作
如果您计划编写自己的 OpenGL ES 2.0 应用程序,以下是如何包装器执行此操作的一些提示
Android 的应用程序是用 Java 编写的,但它们可以使用 JNI(Java 本地接口)调用 C/C++ 代码,在 Android 中,JNI 被称为 NDK(原生开发工具包)。
您可以:
- 编写 Java 包装器和 C++ 代码
- 自 Android 1.5 起可用
- C++ 代码可以与由 Java 创建的 OpenGL ES 上下文进行交互
- 直接从 C++ 创建 OpenGL ES 2.0 上下文(使用 EGL)需要 Android 2.3/Gingerbread/API android-9
- OpenGL ES 2.0 自 Android 2.0/API android-5 起可用
- 示例:NDK 的 hello-gl2 示例
- 依赖于内置的 "NativeActivity" java 包装器,只编写 C++ 代码
- 自 Android 2.3/Gingerbread/API android-9 起可用
- 使用 EGL 创建 OpenGL ES 上下文
- 示例:NDK 的 native-activity 示例(它是 OpenGL ES 1.x,但可以轻松升级)
Android 2.3/Gingerbread/API android-9 引入了 本地活动,它允许在不使用任何 Java 的情况下编写应用程序。
虽然示例提到了默认的 API 级别为 8
,但它应该是 9
。
<uses-sdk android:minSdkVersion="9" />
此外,确保您的清单包含
<application ...
android:hasCode="true"
否则应用程序将无法启动。
您的入口点是 android_main
函数(而不是更常见的 main
或 WinMain
)。为了可移植性,您可以在预处理器级别使用 -Dmain=android_main
[1] 对其进行重命名。
包装器基于 native-activity 示例。它使用处理非阻塞 Android 事件处理的 "android_native_app_glue" 代码。
<!-- Android.mk -->
LOCAL_STATIC_LIBRARIES := android_native_app_glue
...
$(call import-module,android/native_app_glue)
由于我们不直接调用粘合代码(其入口点是 Android 使用的回调,而不是我们),因此 android_native_app_glue.o
可能会被编译器剥离,因此让我们调用其虚拟入口点
// Make sure glue isn't stripped.
app_dummy();
它使用 OpenGL ES 2.0(而不是示例的 OpenGL ES 1.X)
<!-- Android.mk -->
LOCAL_LDLIBS := -llog -landroid -lEGL -lGLESv2
要使用 GLM,我们需要启用 C++ STL
<!-- Application.mk -->
APP_STL := gnustl_static
并宣传其安装位置
<!-- Android.mk -->
LOCAL_CPPFLAGS := -I/usr/src/glm
现在我们可以声明我们的源文件 (tut.cpp)
<!-- Android.mk -->
LOCAL_SRC_FILES := main.c GL/glew.c tut.cpp
要运行构建系统
- 编译 C/C++ 代码
ndk-build NDK_DEBUG=1 V=1
- 准备 Java 构建系统(仅一次)
android update project --name wikibooks-opengl --path . --target "android-10"
- 创建 .apk 包
ant debug
- 安装它
ant installd
# or manually:
adb install -r bin/wikibooks-opengl.apk
- 清理
ndk-build clean
ant clean
我们将这些命令包含在包装器 Makefile
中。
我们需要告诉 EGL 创建一个版本为 2.0 的 OpenGL ES(而不是 1.x)。
首先,在请求可用上下文时
const EGLint attribs[] = {
...
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL_NONE
};
...
eglChooseConfig(display, attribs, &config, 1, &numConfigs);
其次,在创建上下文时
static const EGLint ctx_attribs[] = {
EGL_CONTEXT_CLIENT_VERSION, 2,
EGL_NONE
};
context = eglCreateContext(display, config, EGL_NO_CONTEXT, ctx_attribs);
(在 Java 代码中:)
setEGLContextClientVersion(2);
// or in a custom Renderer:
int[] attrib_list = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL10.EGL_NONE };
EGLContext context = egl.eglCreateContext(display, eglConfig, EGL10.EGL_NO_CONTEXT, attrib_list);
这是一个好的做法,但不是强制性的,在您的 AndroidManifest.xml
中声明 GLES 2.0 需求
<uses-feature android:glEsVersion="0x00020000"></uses-feature>
<uses-sdk android:targetSdkVersion="9" android:minSdkVersion="9"></uses-sdk>
当用户进入主页(或接到电话)时,您的应用程序会被暂停。当用户返回您的应用程序时,它会恢复,但 OpenGL 上下文可能会丢失。在这种情况下,您需要重新加载所有 GPU 端资源(VBO、纹理等)。有一个 Android 事件可以检测何时恢复您的应用程序。
同样,当用户按下后退按钮时,应用程序会被销毁,但它仍然驻留在内存中,并且可以重新启动。
对于我们的包装器,我们认为 GLUT 应用程序通常不是为恢复 OpenGL 上下文而设计的,更不用说重置所有静态分配的变量了。因此,当上下文丢失时,应用程序会完全退出 - 就像在桌面上的应用程序窗口关闭时一样。
即使我们编写的是本地代码,我们的应用程序仍然是通过 Java 进程启动的,使用 android.app.NativeActivity 内置活动。该进程负责接收设备事件并将其转发到我们的应用程序。
工作流程
- Android 操作系统将事件发送到 NativeActivity Java 进程
- Java 活动框架调用相应的活动回调函数(例如,例如
protected void onLowMemory()
) - NativeActivity 在 android_app_NativeActivity.cpp 中调用其 JNI 匹配函数(例如,
void onLowMemory_native(...)
) android_app_NativeActivity.cpp
在android_native_app_glue.c
中调用匹配的 NativeCode 回调(例如,void onLowMemory(...)
)android_native_app_glue.c
通过 Cpipe(2)
(例如,APP_CMD_LOW_MEMORY
)写入消息,并立即返回,以防止 Java 进程卡住(否则用户将被提示将其杀死)- 在我们的本地应用程序中,我们会定期检查事件队列并调用
android_native_app_glue.c
的process_cmd
(或process_input
) - 向上返回一层到
android_native_app_glue.c
,process_cmd
在其中执行事件前和事件后的通用钩子,并在中间调用我们的应用程序onAppCmd
回调 - 返回到我们的应用程序中,
onAppCmd
钩子(例如,engine_handle_cmd
)最终处理事件!
Android 应用程序通常会从其 .apk 文件(实际上是一个 Zip 存档)中提取资源(例如着色器或网格)。
- 资源位于 res/ 子文件夹中(例如 res/layout/);有 Android 函数可以根据它们的类型加载它们
- 资产位于 assets/ 文件夹中,并通过更传统的目录结构进行访问
这对 GLUT 应用程序来说并不常见,因此让我们尝试以透明的方式提供资源
- 使用 fopen/open 的包装器
- 使用 LD_PRELOAD 加载,例如 zlibc
- 使用内核 ptrace 钩子
- 在我们的 .cpp 文件中重新定义 fopen
- 预先提取文件
使用 fopen/open 包装器实现起来很麻烦,因为我们的应用程序是通过 JNI 调用的。这意味着我们不能在设置 LD_PRELOAD 后只 execv
另一个应用程序。相反,我们需要启动子进程,将所有 Android 事件转发到它,并设置 IPC 以共享 android_app
和 ALooper
数据结构。ptrace 也需要一个子进程。
在本地重新定义 fopen 将适用于 C 的 fopen
,但不适用于 C++ 的 cout
。
预先提取资产需要额外的磁盘空间来存储文件,但这是更合理的解决方案。
开发人员一直在努力在 NDK 中轻松访问资源
- Android API:您可以通过 JNI 调用 Android Java 函数,但获取文件描述符需要使用非官方函数,并且只适用于未压缩文件;使用 Java 缓冲区操作来代替非常繁琐的 C/C++
- libzip:您可以轻松地使用 libzip 访问 .apk,但您需要在构建系统中集成该库
- NDK API:在 Android 2.3/Gingerbread/API android-9 中,终于有一个 NDK api 来访问资源
让我们使用 NDK API。它对开发人员来说也不透明(没有 fopen/cout 替代),但使用起来相当容易。
有点棘手的是,从我们的本地活动中的 Java/JNI 中获取 AssetManager。
注意:我们将使用稍微简化的 JNI C++ 语法(而不是 C 语法)。
首先,我们的本地活动在其自己的线程中运行,因此在 android_main
中检索 JNI 句柄时需要谨慎
JNIEnv* env = state_param->activity->env;
JavaVM* vm = state_param->activity->vm;
vm->AttachCurrentThread(&env, NULL);
然后让我们获取调用 NativeActivity 实例的句柄
jclass activityClass = env->GetObjectClass(state_param->activity->clazz);
我们还需要决定在哪里提取文件。我们将使用应用程序的标准缓存目录
// Get path to cache dir (/data/data/org.wikibooks.OpenGL/cache)
jmethodID getCacheDir = env->GetMethodID(activityClass, "getCacheDir", "()Ljava/io/File;");
jobject file = env->CallObjectMethod(state_param->activity->clazz, getCacheDir);
jclass fileClass = env->FindClass("java/io/File");
jmethodID getAbsolutePath = env->GetMethodID(fileClass, "getAbsolutePath", "()Ljava/lang/String;");
jstring jpath = (jstring)env->CallObjectMethod(file, getAbsolutePath);
const char* app_dir = env->GetStringUTFChars(jpath, NULL);
// chdir in the application cache directory
LOGI("app_dir: %s", app_dir);
chdir(app_dir);
env->ReleaseStringUTFChars(jpath, app_dir);
现在我们可以获取 NativeActivity AssetManager
#include <android/asset_manager.h>
jobject assetManager = state_param->activity->assetManager;
AAssetManager* mgr = AAssetManager_fromJava(env, assetManager);
实际的提取很简单:浏览所有文件并逐个将它们复制到磁盘上
AAssetDir* assetDir = AAssetManager_openDir(mgr, "");
const char* filename = (const char*)NULL;
while ((filename = AAssetDir_getNextFileName(assetDir)) != NULL) {
AAsset* asset = AAssetManager_open(mgr, filename, AASSET_MODE_STREAMING);
char buf[BUFSIZ];
int nb_read = 0;
FILE* out = fopen(filename, "w");
while ((nb_read = AAsset_read(asset, buf, BUFSIZ)) > 0)
fwrite(buf, nb_read, 1, out);
fclose(out);
AAsset_close(asset);
}
AAssetDir_close(assetDir);
现在,应用程序可以使用普通的 fopen/cout 访问所有文件。
此技术适用于我们的教程,但不适用于大型应用程序。在这种情况下,您可以:
- 请求 SD 卡的写入权限,并将文件提取到那里(这是 SDL Android 端口所做的),
- 使用一个围绕文件访问的包装器,它在 Android 上使用 AssetManager(注意,它只读访问)
通过设置
<activity ...
android:screenOrientation="portrait"
您的应用程序仅在纵向模式下运行,与设备方向或形状无关。这并不推荐,但对于某些游戏可能有用。
为了更有效地处理方向,您理论上需要检查 onSurfaceChanged
事件。android_app_NativeActivity.cpp
包装器中的 onSurfaceChanged_native
处理程序似乎没有在方向更改时适当创建 onNativeWindowResized
事件,因此我们将定期监控它。
/* glutMainLoop */
int32_t lastWidth = -1;
int32_t lastHeight = -1;
// loop waiting for stuff to do.
while (1) {
...
int32_t newWidth = ANativeWindow_getWidth(engine.app->window);
int32_t newHeight = ANativeWindow_getHeight(engine.app->window);
if (newWidth != lastWidth || newHeight != lastHeight) {
lastWidth = newWidth;
lastHeight = newHeight;
onNativeWindowResized(engine.app->activity, engine.app->window);
// Process new resize event :)
continue;
}
现在我们可以处理事件了。
static void onNativeWindowResized(ANativeActivity* activity, ANativeWindow* window) {
struct android_app* android_app = (struct android_app*)activity->instance;
LOGI("onNativeWindowResized");
// Sent an event to the queue so it gets handled in the app thread
// after other waiting events, rather than asynchronously in the
// native_app_glue event thread:
android_app_write_cmd(android_app, APP_CMD_WINDOW_RESIZED);
}
注意:可以处理 APP_CMD_CONFIG_CHANGED
事件,但它发生在屏幕调整大小之前,因此太早获取新的屏幕大小。
Android 只能在缓冲区交换后检测到新的屏幕大小,因此让我们滥用另一个钩子来获取调整大小事件。
/* android_main */
state_param->activity->callbacks->onContentRectChanged = onContentRectChanged;
...
static void onContentRectChanged(ANativeActivity* activity, const ARect* rect) {
LOGI("onContentRectChanged: l=%d,t=%d,r=%d,b=%d", rect->left, rect->top, rect->right, rect->bottom);
// Make Android realize the screen size changed, needed when the
// GLUT app refreshes only on event rather than in loop. Beware
// that we're not in the GLUT thread here, but in the event one.
glutPostRedisplay();
}
我们从 native-activity 示例中重复使用 engine_handle_input
。
当事件未直接处理时,return 0
非常重要,以便 Android 系统进行处理。例如,我们通常让 Android 处理返回按钮。
NativeActivity 框架似乎不会发送适当的重复事件:按键在完全相同的时间被按下和释放,并且重复次数始终为 0。因此,似乎无法处理来自 Hacker's Keyboard 的箭头键,除非重写框架的一部分。
运动(触摸屏)和键盘事件通过相同的通道处理。
为了允许没有键盘的用户使用箭头键,我们在左下角实现了一个虚拟键盘 (VPAD),它在触摸屏上激活。我们努力避免将 VPAD 事件与现有的运动事件混合在一起,反之亦然。
- ↑ 这是 SDL 用于 Windows 的
WinMain
的技术。
- https://developer.android.com.cn/sdk/ : Android SDK 主页
- NDK 安装目录中的
docs/NDK-BUILD.html
:构建过程的详细信息 - https://developer.android.com.cn/guide/topics/manifest/manifest-element.html : AndroidManifest.xml 参考
- https://developer.android.com.cn/guide/developing/device.html : 使用 USB 将设备连接到设备的官方文档
- https://developer.android.com.cn/reference/android/app/NativeActivity.html : 关于无 Java 应用程序的官方文档
- https://developer.android.com.cn/sdk/ndk/ : Android NDK,修订版 5(2010 年 12 月) 引入了原生活动
- http://blog.tewdew.com/post/6852907694/using-jni-from-a-native-activity : 从原生活动中使用 JNI
内置 NativeActivity 的源代码