小前言
我最近幾個月,做了一個Google Cardboard的VR程式。由於這個程式比較偏向資料視覺化,沒什麼太多的使用者互動,所以沒有採用Unity之類的平台,而是直接從OpenGL ES練習,等到會了以後再套上Google VR SDK。
(先廣告一下。看完這篇以後,還有延伸閱讀 - 後篇野人獻曝2 - iOS上的OpenGL ES套上Google Cardboard )
我從沒接觸過電腦繪圖的相關程式設計,所以在學OpenGL的過程中,吃了不少苦頭 - 絕大部分的苦頭是 "不知道從何下手"。偏偏網路上雖然找得到的為數不多的範例(OpenGL ES的範例,相對比OpenGL的少很多),都很難直接剪貼套用進來。
現在,這個實驗性的程式做完了,我想著,如果有人來問我要怎樣從0開始做OpenGL ES,我會想告訴他什麼? 我在自我練習的初期,看書、訂閱網路上的視訊教學,花了不少時間,才開始逐漸有了概念。但是這個過程太長了,我回想著要怎樣能讓從0開始的人不要走那麼多冤枉路,於是有了這篇blog。
所以,這將會是一系列的,從iOS上的OpenGL ES、到套上Google VR SDK、接著再到移植到Android... 然後,最後會是我怎樣修改我的程式,讓它達到我要的速度(這後面真的我試了N多種方式實驗)。我花了很長的路才走完 (雖然說走完,但終究其實只做了一個實驗性的小程式),希望我有足夠的力氣與時間把他們全部整理出來。
我不是寫程式的高手、也不是OpenGL的專家,所以,也許在這篇裡面,會有些觀念上的錯誤,但是我自己卻也不會知道。現在寫成這篇,除了給從0開始的人看以外,也希望有高手來指正我的觀念,讓我未來不會做錯。
正文開始
先用五百字入門
一個OpenGL ES專案除了一般的原始碼以外,還需要兩個GLSL語言的shader小程式 - 一個叫做vertex shader,負責決定多邊形 (其實只有三角形) 頂點的位置; 另一個叫做fragment shader,負責決定多邊形(還是一樣其實是三角形而已)裡面的每個像素的顏色。
所以在OpenGL裡面,要繪製一個多邊形(嗯,一堆三角形),除了先期進行的初始化之外,其餘的運作流程大概是這樣:
- 讀入vertex shader和fragment shader,取得shader內的變數位置,以便後面在主程式裡可以設定它們。
- 把要繪製的多邊形(還是一堆三角形,我說)頂點座標陣列,讀入到OpenGL ES的buffer內。
- 在主程式內設定繪製環境(顏色、光影、多邊形的位移),把變量設定到步驟1所取得的位置中。
- 下達"Draw"指令,把多邊形(你想得沒錯,就是一堆三角形)畫出來。
- 重複步驟3與4。
以上的步驟是繪製一個多邊形(三角型們啦)的流程。
做OpenGL,當然要3D、當然要會動。那麼怎樣動呢? 步驟5做的事情是重複步驟3與4... 也就是說,你如果每秒循環N次: "清除畫面,然後在步驟3裡面做一些些變動(位移、顏色、光影),然後到步驟4畫下去" - 那麼出來的結果就是會動的畫面了。由於包括了顏色和光影的變化,所以看起來就是3D動畫繪圖了。
步驟3有點像是砲兵在發射火砲的過程: 調整角度、選擇砲彈、裝填火藥,然後發射。每個步驟都是分開的。Draw的指令沒有座標、顏色,它就是一個"draw it now!"的命令罷了。這部分是OpenGL跟其他常見的canvas drawing最大的不同。在所有的OpenGL的說明文章上都會這樣說: "OpenGL是一個狀態機" - 例如: 你把顏色設定黃色,那麼接下來所有做的事情全部都是黃色,直到你改成別的顏色。
(到上面為止是776字,500字破功了... 不過我不想修正了)
有了以上的基本觀念以後,我們就可以拿簡單的範例開幹了(以下是在Xcode 8.2上進行的)。
先試試看再說
Xcode打開以後,新建一個專案。在專案類型的選擇畫面上,選擇"Game"。
然後幫這個專案取個名字。
底下的Language選Objective-C(廢話,我標題就說了),Game Technology選"OpenGL ES"。
專案建立好之後,二話不說直接先run一下看看。
你會看到如下的兩個方塊在轉。
OK,現在我們開始就靠著這個範例,逐步弄清楚在iOS的專案裡面的OpenGL ES程式是怎樣的。
這個範例說好用是真的很好用 - 它比你在網路上找到的任何其他 (其實也沒幾個) 的範例都來得好: 既會動、又夠簡短。其他的網路上的程式,要就是不著邊際的畫一個三角形,搞得你練完以後完全想不出來要怎樣實際使用 (誰要用幾百行程式畫一個不會動的三角形); 要就是給你一個一千行的程式當作入門範例,讓你看到就兩腳發軟,完全無從學起。
正式動手
不過,在開始拿著這個範例當入門之前,我會建議先把它小小改一下,原因如下:
iOS版的OpenGL.framework裡面的提供了兩種方式做OpenGL ES,一個是正常的OpenGL ES集合,另一個是GLKit。GLKit把繁瑣的OpenGL ES的函數呼叫重新包裝了一次,讓你寫起來不那麽痛苦。但是根據我學習的經驗,還是從OpenGL ES那邊學起比較好,因為後續你自己寫程式遇到問題時,上網Google幾乎找不到什麼樣的範例是使用GLKit的...
所以我們現在先把GLKit的部分丟掉,以免干擾我們學習: (當然,你上手了以後,腦中已經有了OpenGL的一切,此時再回頭使用GLKit也不會是什麼問題 --- 只不過矛盾的是,你上手了以後,原本的那些繁瑣的OpenGL API call對你再也不是問題了)。
把GameViewController.m裡面的這個註解起來 (或是刪掉也可)
@property (strong, nonatomic) GLKBaseEffect *effect;
然後後面接著所有跟這個所造成相關的編譯錯誤也一併註解(刪除)掉。
最後是這段(請看四條註解線的那些部份):
這樣以後,我們就有一個乾淨的OpenGL ES的程式了 - 雖然我們不太可能脫離開GLKit的方便(例如,重繪的loop仍然是由GLKit所驅動,矩陣類的運算還是透過GLKit提供的功能簡便一些),但是至少你沒有使用到GLKit的一些特殊繪圖wrapper了。
改完以後,再跑一次看看 - 現在只看到一個方塊在動了。
好吧,縮減後的程式剩下不到四百行,我們開始研究這個程式是怎麼回事吧!
(前面望文生義的那些初始化呼叫我們就跳過去吧。)
首先從[self setupGL]開始:
你會看到它一開始執行了[self loadShaders]。
於是我們切到loadShaders看看。在loadShader一開始是讀取檔案、檢查錯誤等等部分就不多討論。然後你會看到裡面有個glCreateProgram() - 基本上每個物件的"模型",就是一個program(所以你如果有各種物件的話,就要建立一堆program,每個都有各自的變數、shader一整套)。你把所有跟OpenGL引擎溝通的資料,讀入到這個program裡面,後面再把它叫出來,然後進行繪製就是了。我們來看看這個method裡面的其他兩個重點:
- 綁定shader內的attribute
- 綁定shader內的uniform
我們先從attribute的綁定開始看起:
上面這兩行,要搭配Shader.vs來看:
在Shader.vsh裡面有position和normal兩個attribute,我們透過上面的兩行程式碼,把這兩個attribute的對應位置分別綁定到主程式內的GLKVertexAttribBuffer以及GLKVertexAttribNormal這兩個代號中。於是你從主程式裡面,把要繪製的多邊形(三角形啦三角形啦)座標,傳遞到shader裡面讓OpenGL引擎執行繪製時,只要指定這些代號,OpenGL就知道這個數值是給哪一個變數的。
說明: GLKVertexAttribBuffer和GLKVertexAttribNormal其實是兩個定義在GLKit裡面的常數,你可以直接用他們(畢竟他們的變數名稱容易懂),也可以自己隨便定一些數值,只要不重複就好了。比如說,你的主程式知道9527代表position,9487代表normal,就是這樣。GLKit預先幫你定義好這些數字的常數(從0開始enum),方便你直接使用。你也可以不使用這些,只要後續你自己記得就好。
接著看到綁定shader內的uniform:
一樣,透過glGetUniformLocation來對應Shader.vsh裡面的modelViewProjectionMatrix和normalMatrix兩個變數,好讓你在主程式進行設定。
好吧,問題來了: 為什麼同樣是變數對應,要使用兩個不同的方式呢? attribute和uniform兩種的不同點是在於:
"在繪製一個物件時,每個頂點都要有各自的數值,使用attribute; 相對的,要統一套用在全部每個頂點的數值,使用uniform。"
比如說,你的一個物件有36個頂點座標,所以這36個頂點的數值,是透過attribute傳遞。但是你的這個物件在空間中所在的位置、顏色等,這是適用於所有頂點的, 所以要透過uniform傳遞。
對照著Shader.vsh裡面,最後是把運算的結果指派到gl_Position上面,這個gl_Position就是這個頂點最終在繪製空間裡面的絕對位置。它是這樣來的:
gl_Position = modelViewProjectionMatrix * position;
我們透過投影矩陣 (Model View Projection Matrix) 乘上物件某個頂點的相對座標,來得到最後繪製的絕對位置gl_Position (gl_Position這個名稱是固定的,OpenGL引擎會自動去取用最後這個gl_Position變數座標值來繪製頂點)。
如果你不明白為什麼這樣相乘以後會得到空間裡的絕對位置,也許你需要了解一下基礎線性代數,在這邊就不做說明了。要一知半解的硬幹程式也不是不行,在剛開始學習的時候,這個算式幾乎適用於所有你最後要運算的物件,所以只要照抄就好了。
然後在Shader.vsh中間的一行:
colorVarying = diffuseColor * nDotVP;
這行是運算出這個頂點的顏色。在範例中,畫的是一個藍色的方塊,所以每個頂點的顏色都是淺藍色 - diffuseColor是四維向量:
vec4(0.4, 0.4, 1.0, 1.0)
這是它的R,G,B,A值。由於這是3D繪圖、又加上光影,所以先透過平面法線的運算,算出它的漸層值,然後指派給colorVarying變數。注意colorVarying的宣告是:
verying lowp vec4 colorVarying;
varying表示這個數值將要傳遞給fragment shader(也就是Shader.fsh)。
OpenGL的繪製方式是指定每個頂點的顏色值, 然後傳遞給fragment shader,繪圖引擎會自動把平面之間的每個繪圖像素(填色時,使用的單位就不再是頂點而是像素了)做內插式的漸層著色。至於lowp這個修飾字(另有兩個修飾字highp, mediump)的用途,也許等未來使用OpenGL很熟悉以後,自然會需要了解它到底代表什麼,在寫程式的初期,我建議就是先照抄就好了。
填色的fragment shader (Shader.fsh)程式相對簡單,其實也就是把算出來的顏色指派給gl_FragColor去。一樣,繪圖引擎會自動去取用這個固定的變數的值來著色。
好吧,現在我們知道主程式的資料與GLSL如何掛鉤,以讓OpenGL引擎運作了。Shader負責的是實際"繪製"的部分,至於資料的運算,仍然是靠著主程式來處理的。所以接著我們回到主程式,看看如何開始還有哪些事情要做。
(註: 由於程式繁瑣,所以一些關於設定類的函數呼叫,就不做說明,請自行查閱相關說明文件。如果只想玩玩看,就是照抄就好。這篇文章主要是說明整個程式的運作原理。)
定義物件
跟OpenGL引擎掛勾完畢之後,接著要做的,是把資料餵進去掛勾好的變數去。
首先是頂點的座標。
我在之前的每個段落中,提到的"多邊形",一直強調它是三角形,是吧? 因為OpenGL裡面,只有點、線、三角形平面的概念 - 所以你要畫實際的物件,只能用三角形 (要用點或線湊出物件嗎?)。每個三角形平面,有三個頂點(每個頂點都是三度空間的x,y,z座標)。所以要畫一個正方形,你必須用兩個直角三角形組成,那就需要6個頂點(雖然其中有四個頂點是兩兩重合,但是現在我們先當它有6個頂點)。如果你要畫一個正立方體,那有六個平面,所以會需要36個頂點。
(圖片來源: http://in2gpu.com/2015/07/09/drawing-cube-with-indices/)
在主程式最上方附近有個gCubeVertexData陣列(不剪貼了,自己打開看),上面就定義了36個頂點的xyz座標,以及該頂點的法向量 (一樣,這可能需要基礎線性代數的知識,你也可以跳過照抄)。如果你有興趣,可以自己在紙上面,把這些頂點的位置逐一點出來,看看最後是不是組成一個正立方體 - 一個長寬高為0.5的正立方體。
(跳一下: 那大小為0.5會不會太大或太小? 別擔心,我們要的是"形狀",大小只要在後面要繪製之前,進行縮放即可)
OK我們有了頂點的座標了,怎樣傳遞給OpenGL引擎呢?
先看這三行:
glGenBuffers(1, &_vertexBuffer); glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer); glBufferData(GL_ARRAY_BUFFER, sizeof(gCubeVertexData), gCubeVertexData, GL_STATIC_DRAW);
先呼叫glGenBuffers()來向OpenGL要一個緩衝區,OpenGL準備好緩衝區以後,把緩衝區的handle傳回到_vertexBuffer這個變數上。
然後呼叫glBindBuffer(),告訴OpenGL說,我們現在要針對_vertexBuffer這個緩衝區做動作。 記得文章最前面提到過,"OpenGL是一個狀態機"嗎? 現在就是把"使用中的緩衝區"設定為_vertexBuffer,後續的動作都將套用到這個緩衝區中。
下一行就是把gCubeVertexData填入到緩衝區去了,基本上這是複製主程式的資料到OpenGL緩衝區裡面,就像memcpy一樣。這樣做完以後,接下來所有的繪製,只要指定使用_vertexBuffer這個handle,OpenGL就會取用已經放在那裡面的資料繪製,不再需要任何資料的傳遞了。
接下來這幾行需要更多唇舌:
glEnableVertexAttribArray(GLKVertexAttribPosition); glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, 24, BUFFER_OFFSET(0));
我們剛剛傳送過去的,其實是"一個頂點座標外加一個法向量",這樣算是一個頂點的完整資料,然後有36組這樣的資料。
glEnableVertexAttribArray()這個呼叫,告訴OpenGL說我們現在要啟用稍早前取得的position變數的位置(還記得GLKVertexAttribPosition是什麼意思嗎?)。接著,glVertexAttribPointer則是告訴引擎說,這個位置的記憶體是怎樣的安排: 它是3個數值、型別是float、這些數值需不需要額外的正規化才能使用(不懂什麼意思嗎? 寫GL_FALSE就是啦),以及最後兩個數值更關鍵: "多少個bytes是一筆資料",以及 "我們要把指標指向第幾個byte"。
我們的"一組頂點資料",含有3個座標浮點數,3個法向量浮點數,所以每筆資料有6個浮點數也就是24 bytes。座標是在這24 byte的第一個位置(也就是0)起算(然後向後三組浮點數)。
然後,法向量如法泡製:
glEnableVertexAttribArray(GLKVertexAttribNormal); glVertexAttribPointer(GLKVertexAttribNormal, 3, GL_FLOAT, GL_FALSE, 24, BUFFER_OFFSET(12));
法向量一樣有3個浮點數,正規化我們仍然放GL_FALSE,它的位置是在這24 byte的第12個byte起算(因為前面有3個浮點數的座標值)。
到這裡為止,我們已經告訴了OpenGL引擎頂點的所有資料了。
再回頭看看這幾行程式的前後:
glGenVertexArraysOES(1, &_vertexArray); glBindVertexArrayOES(_vertexArray);
再說一次,OpenGL是一個狀態機。所以稍後我們正式下達繪製指令之前(每次繪製都要),我們還會需要做前述的glBindBuffer、glEnablerVertexAttrib、glVertexAttribPointer等動作,為了節省麻煩,OpenGL提供了一個叫做"Vertex Array Objects (VAO)"的概念:
我們先建立一個VAO,綁定這個VAO,然後接下來所有的glBindBuffer、glEnableVertexAttrib、glVertexAttribPointer這些呼叫,都會被記錄到這個VAO裡面,直到我們呼叫
glBindVertexArrayOES(0);
把這個VAO解除為止。
這樣把VAO紀錄完畢以後,等到後續我們要使用這些數據的時候,只要把這個VAO啟用,OpenGL就會把剛剛存下來的那些buffer的設定載入(別忘了,狀態機狀態機),然後你就可以直接呼叫繪製指令,再把它解除就好了。這樣可以省下很多功夫。
終於,到這裡所有的準備工作都完成了! 先撒花一下吧! (別全部灑完...)
再來就要來到繪製的部分了。
正式繪製
GLKit提供了畫面自動更新的機制,所以只要繼承 GLKViewController 的view controller,都會被自動呼叫update和drawInRect這兩個delegate - 這兩個是必要實作的method。如果你不使用GLKit的機制,那麼你自己要準備render loop(不在這篇文章的說明範圍內)。
在GLKViewController的設計理念上,update的功用是讓你做每個畫面要更新之前的額外運算,它裡面不該涉及到任何的實際繪圖指令 - 你可以在這裡變動你的各項參數、做物件座標的移動、進行矩陣運算等等。然後到了drawInRect的時候,就是真正的下達跟OpenGL 相關的繪圖指令了。
一開始我們有試著執行過這個範例了,是吧? 這個方塊會沿著某個軸心做公轉,同時還自己自轉。這些轉動的計算,就是放在update裡面。
我們看一下我們精簡過以後的update程式碼:
先回到稍早提到的關於attrib與uniform的說明裡面提到的: 屬於每個頂點變化的數值放入attribute裡面,屬於整個物件通用的數值放入uniform裡面。所以,現在我們面對的是"一個畫面的更新",因此你可以想像,這裡面在計算的就是uniform。
把我們定義好的正方體這個物件在繪製平面上移動,也就是計算出這個物件需要移動到哪裡、需要怎樣旋轉、要怎樣的大小(這裡的大小指的是物件要縮放成怎樣的大小,至於視角上面的大小變化 - 在遠方較小在近處較小,這種空間上的問題,OpenGL會處理)。
所以這個函數裡面基本上就是一堆線性代數的矩陣轉換,計算完畢以後等到稍後繪製的時候把它們指派給uniform就可以了。
唯一跟數學無關的呼叫,就是GLKMatrix4MakePerspective,這個呼叫的用途是設定繪製空間的視角大小、投影遠近距離等,請參考別人的說明圖,例如:
(圖片來源: http://www.real3dtutorials.com/tut00002.php)
我功力不夠,實在無法說明太多...。
仍然提醒一下這裡面的旋轉原理。
_rotation這個數值是依照時間值去計算的,這個數值同時被套用在公轉計算以及自轉計算上:
baseModelViewMatrix = GLKMatrix4Rotate(baseModelViewMatrix, _rotation, 0.0f, 1.0f, 0.0f);
你可以在GLKMatrix4Rotate旋轉運算時,分別提供x,y,z軸的分量,我們看到的結果是他的公轉是以y軸為中心做水平旋轉,所以分量是(x,y,z)=(0.0, 1.0, 0.0)。
至於自轉則是三軸一起轉,所以是(x,y,z) = (1.0, 1.0, 1.0)。
modelViewMatrix = GLKMatrix4Rotate(modelViewMatrix, _rotation, 1.0f, 1.0f, 1.0f);
好了,現在終於要下繪圖命令囉。前面我們該做的通通都做完了,所以繪圖命令很簡單:
清除畫面,然後載入一開始loadShader時所建立的program (記得嗎? 每個"模型"是一個program),再帶入剛剛存放的VAO,接著指派modelViewProjectMatrix以及normalMatrix兩個uniform,最後就是一行命令:
glDrawArrays(GL_TRIANGLES, 0, 36);
搞定所有的畫圖工作 - 最後一個參數,是說我們要畫36個頂點 (你可以在glBufferData時扔進去千百個頂點到buffer去以便給很多的program分別利用,但是此刻你只要36個)。如同我第一段說的,這就像砲兵一樣: 所有的準備工作做好,射出砲彈其實也就是按個按鈕罷了。
glDrawArrays的第一個參數是繪製模式,基本上我們是畫"三角形" (我認為初期就是畫三角形就是了)。其他的請參考下面的表格,至於每個數值所真正代表的意義,請自己Google:
(圖片來源: http://apprize.info/programming/opengl_1/10.html)
恭喜,你看完了! 我寫完了!
真的要徹底灑花了!!!
統計: 這個不到四百行的程式,花了九千多個字來說明...
小延伸:
如果你看到這邊,然後照著做完之後,認為你大概知道了,那麼建議你試試看怎樣畫出第二個立方體。
假如你仍然無法想像要怎樣畫上第二個立方體(或第三個第四個...),我在這邊講一下,以免你灰心了:
其實也就是下完glDrawArrays畫完一個立方體以後,再把modelViewProjectMatrix這個矩陣做一下GLKMatrix4Transform移動一下,再指派給uniform,然後再次呼叫glDrawArrays就是了,例如:
for(int i=0; i<10; i++) {
GLKMatrix4 matrix = GLKMatrix4Translate(_modelViewProjectionMatrix, 1.1f * i, 0.0f, 0.0f);
glUniformMatrix4fv(uniforms[UNIFORM_MODELVIEWPROJECTION_MATRIX], 1, 0, matrix.m);
glUniformMatrix3fv(uniforms[UNIFORM_NORMAL_MATRIX], 1, 0, _normalMatrix.m);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
上面我們把update裡面計算好的_modelViewProjectionMatrix,做一下平移0.1個單位,再丟入uniform,執行glDrawArrays。這樣做10次,就畫出一排方塊了。
範例程式片段: https://github.com/junghao/cardboard-snippets/tree/master/opengles-ios-1
感謝分享,內容很詳盡,節省很多查資料的時間
謝謝。希望這篇有幫到你。
顶?
感谢分享,太厉害了,谢谢。