Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> Android開發實例 >> Android 3D 編程:HelloArrow

Android 3D 編程:HelloArrow

編輯:Android開發實例

這是我的 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有更加全面和深刻的理解

下載源碼

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved