Android 3D 编程:HelloArrow

这是我的 Android 翻版“iPhone 3D Programming”系列的第一篇,相当于 OpenGL ES 1.1 的 Hello World

<< 转到索引页

下载源码

涉及 OpenGL 部分的代码基本上克隆原书 HellowArrow 的代码,只是由C++到C的简单移植、以及根据我的理解对个别细节的调整。UI/应用部分,则完全按照Android的方式重新实现,大体上是从我以前的一篇随笔“Android OpenGL ES 1.x 教程的Native实现”拿来。程序运行效果与原 HelloArrow 完全一样:灰色背景绘制一个黄色箭头,这个箭头始终指向上方;当屏幕旋转时,箭头会平滑地旋转90度到正确的方向。运行效果如下:(我的调试机器为Android 2.2,屏幕解析度800*480)

应用程序的层次结构

应用程序总体上分为3个层次--App层、接口层、实现层,如下图所示:

App层控制应用程序的生命周期、实现程序UI、处理UI事件、控制OpenGL的渲染线程

接口层,顾名思义定义了OpenGL绘图操作的接口,供上层调用以实现屏幕绘制(主要是被OpenGL渲染线程调用)。RenderingEngine是一个抽象类,其作用有二:(1)定义OpenGL绘图操作接口,(2)作为两个实现类(RenderingEngine1、RenderingEngine2)的工厂,创建实现类对象。OpenGL绘图操作接口有以下几个:

    public abstract void initialize(int width, int height);

public abstract void render();

public abstract void updateAnimation(int period);

public abstract void onRotate(int rotation);

initialize() 方法用于当窗口初始化完成、显示到屏幕后,对OpenGL的初始化,其参数是窗口的尺寸

render() 方法是在屏幕上绘制一帧。App层的渲染线程基本上一个无限循环,不断地调用RenderingEngine.render() 方法以刷新屏幕。一般要控制刷新率(fps),就是控制渲染线程的循环的快慢

updateAnimation() 方法用于通知实现层对状态进行更新,该方法也是在渲染线程中每个循环调用一次、在render() 被调用之前。这个方法主要用来实现动画,例如,要实现一个物体在屏幕上从左到右移动,内部有一个x状态值,表示物体距屏幕左边的距离。在每一个渲染循环中,将x的值增加一点点(updateAnimation()方法)、然后绘制在屏幕上(render()方法),这样就形成了连续的动画。updateAnimation()方法带有一个参数,为此次循环距离上次循环的时间间隔,单位为毫秒(1ms=0.001s)。利用这个时间参数,就可以控制动画的速度,对于前面的物体从左到右移动的例子,物体的运动速度=x的增量/一次循环的周期

onRotate() 方法用来在屏幕旋转时将旋转角度通知到底层,底层对此事件进行相应处理,例如,本篇HelloArrow例子中,当屏幕旋转时,内部会开始旋转箭头的动画、直至旋转到正确角度才结束动画

RenderingEngine1、RenderingEngine2是RenderingEngine的具体类(或者说实现类,虽然它们的工作又进一步交给底层去完成),RenderingEngine1以OpenGL ES 1.1 实现,RenderingEngine2 以 OpenGL ES 2.0 实现(本篇暂未实现,To be continue:-)。App层要选择所要使用的OpenGL 版本,只要获取不同的类的对象就可以了,二者的接口是一样的,都实现了RenderingEngine

客户端利用RenderingEngine的工厂方法取得RenderingEngine1或者RenderingEngine2的实例,而不是直接实例化这两个类

RenderingEngine1、RenderingEngine2的方法都声明为native的,由实现层来完成最终的工作。实现层的3个组件,与接口层的3个组件一一对应。其中RenderingEngine1.c 实现了 RenderingEngine1 类、RenderingEngine2.c 实现了 RenderingEngine2 类的接口。在实现层内部,RenderingEngine1.c 和 RenderingEngine2.c 都“实现”了头文件 RenderingEngine.h,RenderingEngine.h 与 接口层的 RenderingEngine 类又是相互对应的。接口层、实现层的6个组件,都是相同的接口--RenderingEngine所定义的4个接口方法

渲染线程

前面说了,渲染线程就是一个“死”循环,在每个循环中调用 RenderingEngine.updateAnimation()、RenderingEngine.render() 方法。关于渲染线程的具体实现,请参考我以前的随笔“Android OpenGL ES 1.x 教程的Native实现”、或者本篇的源码

处理屏幕旋转

Android自动地为应用程序处理了屏幕旋转,对大多数情况,这很贴心。但是它并不提供“屏幕旋转事件”的通知(据我所知),这个就不太好了。好比在我国,大多数人从一出生就自动被“戴表”了,一般没有机会自己“戴表”自己。对于我们这个例子,要求屏幕旋转时,Activity并不会重新启动(View也不要重新Layout),因为我们要自己处理动画。怎么办呢?我以前的随笔“获取Android设备的方向”就探讨了这个问题。简单地说,是应用程序自己监听g-sensor的运动加速度,然后据此计算出设备旋转的角度,然后根据这个旋转角度来判断屏幕的4个方向(见Surface.ROTATION_XXX等4个常量)。这里不重复具体算法了,抓紧时间把OpenGL写完,我要睡觉了:)

RenderingEngine1.c 的实现

RenderingEngine1.c 是 RenderingEngine 接口的 OpenGL ES 1.1 实现,前面说过的。所有的 OpenGL ES 操作都在里面,这个正是我要学习的重点(也是我为什么打算写这一系列随笔的原因),下面说得尽量详细。复述是梳理自己对知识掌握程度和漏洞的好方法(更好的方法是交流)

RenderingEngine.h

前面说,在实现层内部,RenderingEngine1.c 实现了RenderingEngine.h ,而 RenderingEngine.h 与接口层的 RenderingEngine 是互相对应的关系。要说 RenderingEngine1.c,必须先交代一下 RenderingEngine.h,看看代码:

#ifndef _RENDERING_ENGINE_H
#define _RENDERING_ENGINE_H

#define ROTATION_0 0
#define ROTATION_90 1
#define ROTATION_180 2
#define ROTATION_270 3

void initialize(int width, int height);

void render();

void updateAnimation(int period);

void onRotate(int rotation);

#endif //_RENDERING_ENGINE_H

除了定义了4个旋转角度的常量,剩下的就是对 RenderingEngine 类的4个接口方法的照搬。为什么要这么做,来不及了、也怕讲不清楚。。。

initialize() 方法

虽然正确的说法是“函数”,但我习惯说“方法”了。后面如果我没有记起、在该说“函数”的地方错说成“方法”,敬请指出、原谅

我先贴 initialize() 函数的完整代码:

void initialize(int width, int height) {

glViewport(0, 0, width, height);
glMatrixMode(GL_PROJECTION);
// Initialize the projection matrix.
const float maxX = 2.0f;
const float maxY = 3.0f;
glOrthof(-maxX, maxX, -maxY, maxY, -1.0f, 1.0f);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
}

initialize() 方法是在程序刚刚启动、窗口刚刚ready时被调用的,带有2个参数,分别是窗口(也就是OpenGL的“画布”)的宽度和高度,单位是像素(其实无所谓)

首先,OpenGL设置它的“视口”(Viewport),就是说,最终OpenGL会把一帧图像绘制到视口内、并充满视口,这就意味着,如果图像的长/宽比与视口的长宽比不相等,图像就会在某一个方向被拉伸、变形。如果要保持图像的长宽比,就可以在设置视口时,在窗口范围内选取一个合适的视口。比如说让它居中,窗口内视口之外的部分就是空白。现在的电影不是流行宽屏(16:9)吗,如果显示器是老的4:3的你就很悲摧了,全屏播放时,上下的黑边加起来差不多比视频本身的面积还大了。长宽比就是这样一个概念,然后在OpenGL里,通过glViewport()方法来控制

glViewport() 方法带有4个参数,分别表示视口的左下角坐标、宽度和高度。上面的代码里将它设置成左下角位于原点、尺寸与窗口相同,也就是充满了窗口。这也就注定了我们这个程序的图像会产生变形

接下来,glMatrixMode()函数,将OpenGL的“当前”矩阵模式切换到“投影矩阵”。这里有几个概念。(1)OpenGL是一个状态机,在互斥的一组状态中,某一时刻只有其中一个状态是“当前的”,也就是会一直生效的,直至你把它切换到另一个状态,那时就是另一个状态变成“当前”状态了。(2)矩阵模式:显然“矩阵模式”就是OpenGL 的一种状态了,但是我不是很清楚具体还有哪几种矩阵、每种矩阵具体作什么用。我目前知道的是(不一定正确,有待学习):在设置投影的参数之前,需要将矩阵模式切换到“投影矩阵”(GL_PROJECTION);在绘图前,将它切换到“模型视图”矩阵(GL_MODELVIEW)。矩阵的作用,我以前学过一点点工科3D图形学,但是现在已经忘记了,大概只知道在三维空间对一个物体移动、缩放、旋转,都可以将它的当前坐标乘以一个矩阵就可以了,具体的我忘记了。iPhone 3D Programming 在第2章会讲,到时候我再仔细研究吧。基本的意思大概是:先切换到一种矩阵模式,然后修改当前矩阵的值,到时候OpenGL会用这个矩阵进行有关的运算。先这样吧,存疑。(3)投影矩阵:就是设置OpenGL怎么将3D空间内的物体,投影到一个2D平面上(最终绘制到我们的屏幕上),它有正交和透视两种投影的方式

正交投影(Orthographic Projection),就是物体的顶点和边,以平行的方式投射到投影平面上,物体本身有多大,投影过来还是多大;透视投影(Perspective Projection),是类似人的眼睛或者照相机的工作原理,投射线(我暂且这么说吧)会从物体汇聚到人眼或相机。想象要是用正交投影,我们要长多大的眼睛才够用啊?看下面的对比图示就清楚了:(左边是正交,右边透视)

上面的代码,调用glOrthof() 函数,设置了正交投影的一个立方体的范围,这个范围内的物体会投影到平面,这个范围之外的就裁减掉了。函数的6个参数分别代表立方体的6个面:左面的x、右面的x、底面的y、顶面的y、前面(靠近观察者)的z、后面(远离观察者)的z,如下图:(图中z的方向标反了?TBD)

然后,再次调用glMatrixMode()函数切换到GL_MODELVIEW矩阵。我目前的理解是:在要做绘图操作之前,切换到MODELVIEW矩阵。具体的后面再学

最后一个语句 glLoadIdentity(),这是将当前矩阵初始化为单位矩阵,线性数学里面学过,单位矩阵就是对角线为1的矩阵。目前我的理解是:这是矩阵的一个最初使的状态,后面绘制图形时,再往这个矩阵里面修改它的值

render() 方法

render() 方法的工作是绘制出一帧,也就是一个连续的动画的所有画面中的一幅。render() 方法被渲染线程循环调用,就形成了动画。如果每一帧图像都是一样的,则看起来是静止的画面,其实内部还是在不停地重绘

代码:

struct Vertex {
float position[2];
float color[4];
};

// Define the positions and colors of two triangles.
static const struct Vertex vertices[] = { //
{ { -0.5f, -0.866f }, { 1.0f, 1.0f, 0.5f, 1.0f } },//
{ { 0.5f, -0.866f }, { 1.0f, 1.0f, 0.5f, 1.0f } },//
{ { 0, 1.0f }, { 1.0f, 1.0f, 0.5f, 1.0f } }, //
{ { -0.5f, -0.866f }, { 0.5f, 0.5f, 0.5f } }, //
{ { 0.5f, -0.866f }, { 0.5f, 0.5f, 0.5f } },//
{ { 0, -0.4f }, { 0.5f, 0.5f, 0.5f } }, //
};

static int currentDegree = 0;

void render() {
glClearColor(0.5f, 0.5f, 0.5f, 1);
glClear(GL_COLOR_BUFFER_BIT);

glPushMatrix();
glRotatef(-currentDegree, 0, 0, 1);

glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_COLOR_ARRAY);
glVertexPointer(2, GL_FLOAT, sizeof(struct Vertex), vertices[0].position);
glColorPointer(4, GL_FLOAT, sizeof(struct Vertex), vertices[0].color);
GLsizei vertexCount = sizeof(vertices) / sizeof(struct Vertex);
glDrawArrays(GL_TRIANGLES, 0, vertexCount);
glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_COLOR_ARRAY);

glPopMatrix();
}

首先定义了一个Vertex结构体,表示一个顶点。顶点由它的坐标和颜色构成,这里使用了2D坐标(x, y),其z坐标缺省为0,即:(x, y, 0)。颜色由RGBA 4个分量构成(A可省略,默认为1.0),这与Android 2D图形API(android.graphics)中的一样。每个颜色分量的范围为0~1.0

之后是一个Vertext顶点数组。我们这个程序是要绘制一个箭头,这个箭头是由两个三角形叠加形成的,其中一个三角形的颜色与画面的背景色一样,因此,看起来就相当于从第一个三角形中减去了第二个三角形,这样形成了我们看到的箭头形状:

可以在纸上画出这6个顶点,前3个连起来形成大的三角形,后3个是小三角形

有一个全局变量currentDegree,这是用来记录当前帧箭头的旋转角度的状态变量。当屏幕旋转时,箭头会平滑地旋转到正确向上的方向。在这个旋转的过程中,每一帧的currentDegree都会增加/减小一点,直至旋转到目标角度、动画停止

render() 方法头2个语句,将画面清成指定的颜色。这里又出现了一个新的概念:“清除颜色”缓存(GL_COLOR_BUFFER_BIT)。我的理解:OpenGL有n种缓存,其中一个是“清除颜色”缓存,用来存放清屏的颜色。glClear() 的就是用前面所指定的颜色进行清屏

glPushMatrix()、以及末尾的glPopMatrix()组成一对。每一种OpenGL 矩阵都包含多个矩阵,构成一个栈,栈顶的矩阵为当前矩阵。根据OpenGL ES的规范,MODELVIEW矩阵的栈深至少为16。glPushMatrix() 将当前矩阵(即:位于栈顶的)复制、并压入栈。glPopMatrix() 则弹出并丢弃栈顶矩阵,此时,原来的第二个矩阵--也就是glPushMatrix()之前的栈顶矩阵--成为新的栈顶矩阵,这样就实现了对当前矩阵的备份、恢复

在initialize()函数中,当前矩阵模式已经切换为MODELVIEW,并且将当前矩阵设为单位矩阵。当进入render() 函数后,由于glPushMatrix()-glPopMatrix()函数对的作用,无论render()被调用多少次,在进入函数的前、后时刻,当前矩阵始终为单位矩阵。而在glPushMatrix()-glPopMatrix()函数对之间对当前矩阵的任何更改都将被丢弃
glRotatef() 函数会用一个“旋转矩阵”与当前矩阵相乘并取代当前矩阵,由于当前矩阵为单位矩阵,因此,glRotate() 的结果是旋转矩阵成为当前矩阵。在这之后绘制的物体,都会被按参数指定的方式旋转指定的角度。该函数接收4个参数,第1个表示旋转的角度的度数,后3个参数构成的矢量(x, y, z)表示旋转轴。在我们的代码里面该矢量为(0, 0, 1),即z轴。z轴是垂直于屏幕向里的,因此,这里的结果是,屏幕(x-y平面)上的图形绕原点旋转了-currentDegree度。关于currentDegress的值以及正负号,后面再说明

接下来先看glEnableClientState()-glDisableClientState()之间的代码。OpenGL 绘图的方式是:在绘图之前,将要绘制的图形的顶点坐标以及颜色以数组形式提交给OpenGL,其中,glVertexPointer() 函数提交顶点坐标数组,glColorPointer()函数提交顶点的颜色数组。这两个函数的参数一样,第1个参数表示每个顶点包含几个分量,例如,我们代码中指定坐标由2个分量(x、y,z缺省为0)、颜色由4个分量(RGBA)组成;第2个参数表示所提交的数组元素的类型,OpenGL根据此类型确定每个数组元素的长度(字节数);第3个参数表示数组中相邻顶点之间的偏移量,例如,我们的vertices数组中是[2个坐标值、4个颜色值、2个坐标值、4个颜色值。。。]的形式存放数据,因此,一个顶点的坐标与上一个顶点的坐标值之间相隔了4个颜色值,而一个顶点的颜色值与上一个顶点的颜色值之间相隔了2个坐标值。偏移量为0则表示相邻顶点紧凑排列,中间没有任何间隔。最后一个参数表示所提交数组的地址

数据提交给OpenGL之后,通过glDrawArrays() 函数,指示OpenGL利用之前所提交的数组进行图形绘制。3个参数分别表示绘制图形的类别、数组中第1个顶点的索引、数组中顶点个数。我们这里绘制的图形是三角形(TRIANGLE),由于之前glVertext/ColorPointer()函数中提供的数组地址已经取到了第1个顶点,因此,这里第2个参数的值为0;顶点个数由sizeof操作符求得,也可以直接改为6

现在再说glEnable/DisableClientState() 函数。上面所说的glXxxPointer()->glDrawArrays()的绘图方式,提交给OpenGL的数据存放在OpenGL的客户端,但是必须通过glEnableClientState()函数显式地开启这个功能。这是规定,遵照就行了
关于客户端、服务器端,我在之前学习 EGL 规范时了解到:OpenGL 以C-S模式运行(与X Window一样),应用程序是客户端,OpenGL为服务器端,客户端通过OpenGL命令(函数)与服务器通讯;OpenGL的状态数据,既可以放在客户端(glEnableClientState()),也可以放在服务器端、但是要创建服务器端的buffer(好像如此,存疑)
小结一下:render()函数在屏幕上以一定旋转角度绘制出箭头的图形,这个旋转角度是-currentDegree,它的值可以在render() 函数之外控制/修改

计算旋转角度

箭头的旋转角度currentDegree 是在 onRotate()、updateAnimation()两个函数中更新(以实现动画)的

onRotate() 在屏幕发生旋转时被调用,同时接收到一个参数,表示屏幕旋转的角度,并把这个角度(desiredDegree)记录下来:

static int desiredDegree = 0;
static int currentDegree = 0;

void onRotate(int rotation) {
desiredDegree = 90 * rotation;
}

初始状态时,currentDegree与desiredDegree相等(都为0);当屏幕旋转一定角度后,“理想的”结果是箭头也旋转相同的角度、currentDegree再次变得与desiredDegree相等。因此,可以把这两个变量都理解成绝对值,但实际上它们的方向应该相反

在渲染线程的每次循环中,updateAnimation() 都被调用。在这个函数中,就对比currentDegree与desiredDegree之间还相差多少,如果已经相等(达到理想状态)了,就不再更新currentDegree的值了。否则,根据循环的周期、我们期望的动画速度,计算出在这一帧应该旋转多少角度。思路就是这样的,具体的计算如下:

static const int DEGREES_PER_SEC = 360;

static int desiredDegree = 0;
static int currentDegree = 0;

static int getRotateDir() {
int delta = desiredDegree - currentDegree;
if (0 == delta) {
return 0;
}

return ((delta > 0 && delta <= 180) || (delta < -180)) ? 1 : -1;
}

void updateAnimation(int period) {

int dir = getRotateDir();
if (0 == dir) {
return;
}

int degrees = (int) (period / 1000.0f * DEGREES_PER_SEC);
currentDegree += degrees * dir;

// Ensure that the angle stays within [0, 360).
if (currentDegree >= 360) {
currentDegree -= 360;
} else if (currentDegree < 0) {
currentDegree += 360;
}

// If the rotation direction changed, then we overshot the desired angle.
if (getRotateDir() != dir) {
currentDegree = desiredDegree;
}
}

这里简单地解释一下:DEGREES_PER_SEC是希望动画的速度为360度/s;getRotateDir() 是根据desiredDegree与currentDegree之间的大小关系,计算出旋转的方向(逆时针/顺时针),例如说,二者相差90度,就不要让它绕一个大圈旋转270,虽然最终也到达了我们期望的位置,但是这个旋转的过程与人的主观预期相悖;updateAnimation() 函数计算出此次应该旋转“到”的角度,并且确保在有效范围内

最后回到前面留下的一个问题:在render()函数中调用glRotatef()时,指定旋转角度为-currentDegree,这是因为,箭头的旋转方向应该与屏幕旋转方向的相反

其他说明

实现层的文件组织结构

我对实现层的文件结构组织如下。RenderingEngine1.c 放在单独的子文件夹中,虽然目前只有它一个源文件;后面实现了RenderingEngine2.c 后,也会同理放在另一个单独的子文件夹 RenderingEngine2_impl 中;JNI“胶合”代码以及 RenderingEngine.h 都放在 jni/ 根目录

RenderingEngine1.c 单独编译成一个共享库 RenderingEngine1_impl;同理,后面实现了RenderingEngine2_impl 也会编译成独立的共享库

JNI胶合代码主要是简单地调用RenderingEngine1_impl、RenderingEngine2_impl 这两个库。我们通过在 jni/Android.mk 中指定这种依赖关系:

LOCAL_PATH := $(call my-dir)

# Include make files in sub dirs
# Note that LOCAL_PATH variable will get cleared
# therefor we have to restore it later
LOCAL_PATH_restore := $(LOCAL_PATH)
include $(call all-subdir-makefiles)
LOCAL_PATH := $(LOCAL_PATH_restore)

# Build libRenderingEngine1.so, which links against libRenderingEngine1_impl.so
include $(CLEAR_VARS)
LOCAL_MODULE := RenderingEngine1
LOCAL_SRC_FILES := unidroid_android3d_helloarrow_RenderingEngine1.c
LOCAL_SHARED_LIBRARIES := RenderingEngine1_impl
include $(BUILD_SHARED_LIBRARY)

首先注意 include $(call all-subdir-makefiles) 一行,它的作用是将所有存在Android.mk文件的子目录都进行编译。但是,实践证明,它会导致在脚本开头所赋值的LOCAL_PATH变量的值变为NDK的根目录,最终导致接下来的 RenderingEngine1 模块的编译失败。因此我在它的前后分别对LOCAL_PATH变量进行了保存、恢复

在编译 RenderingEngine1 库时,通过 LOCAL_SHARED_LIBRARIES 指定了它需要动态链接到 RenderingEngine1_impl 库

本篇完!

通过对HelloArrow例子的学习和分析、以及前些天所作的准备工作,初尝 OpenGL ES 编程的滋味(还不错),不过还有不少关键概念需要进一步学习和澄清。但我很有信心的是,等写完计划中的一整个系列后,一定会对OpenGL ES有更加全面和深刻的理解

<< 转到索引页

下载源码

posted on 2011-10-18 10:16  bye_passer  阅读(3331)  评论(0编辑  收藏  举报

导航