這篇的內容是,怎樣把我之前練會的OpenGL ES程式,套用上GVR SDK,讓它成為具備VR效果的程式。對於從0開始的人,在知道了怎樣在iOS上面寫出一個OpenGL ES的程式以後,後續套上Google VR SDK (GVR) 相比就簡單多了- 需要理解的只是GVR SDK的運作模式。
延伸閱讀 - 前篇野人獻曝 - iOS上的OpenGL ES從0開始 (Objective-C)
GVR SDK做些什麼呢? 你把你的程式"套入" GVR SDK的框架之中以後,你的程式會具備:
- 自動分成左右眼顯示模式,而GVR SDK自動幫你計算左右眼之間的些微視差效果,這樣你戴上了Google Cardboard Viewer以後,會感覺到立體的感覺。就像你看3D電影要戴眼鏡一樣。
- GVR SDK會根據你手機內的陀螺儀所傳來的仰角、轉動、偏移等資訊,計算出場景的投影矩陣 (projection matrix)。於是你的程式可以利用這個投影矩陣,運算出你要繪製的內容 - 因此你移動手機時,你的程式可以有所不同的反饋。
- 如果你的程式有音效,你可以指定你的聲音在虛擬空間中的方位,GVR SDK會變換左右耳的聲音平衡,從而讓你感覺聲音的來源。
- 使用者送出的控制事件(例如使用者點擊了螢幕、按下音量鍵等等)。
從官方範例玩起
先看完官方網站的Get Started,裡面利用一個範例的專案,說明程式如何運用GVR SDK,並不難,看完以後大致上就會了解了。接著我們就把Google VR SDK for iOS下載下來,將TreasureHunt這個範例編譯起來玩 (這個範例使用的依存管理是Cocoapods)。
如果你手上有Google Cardboard Viewer的話(沒有Cardbaord Viewer? 上網買一個吧! 上網買一個Cardboard Viewer所需要花的時間,比你看完這篇所要花的時間少很多...不過買的時候請注意不要買錯就是了),你戴上這個紙盒,頭轉來轉去,會發現有個方塊在畫面中,你盯著方塊看幾秒以後,方塊會被你的視線"射掉"。雖然很簡單,但至少我們想要學會的"互動(依照頭部轉動來變換內容)"功能,這個範例程式有,那就研究吧。
打開專案以後,你會發現整個專案的重點,是在TreasureHuntRenderer裡面。所以我們要做的,很明顯是把之前做的那個OpenGL ES練習程式的片段,塞入到這個程式裡面。不過諷刺的是,塞入以後其實程式變得更陽春不是嗎? 我們畫的方塊並沒有更高明吧? 不過呢,不管好壞,怎樣都是自己清楚的片段,所以後續擴充的時候會比較容易一些。
我們看一下現在的TreasureHuntRenderer,初步會發現只要:
- 把我們的初始化(包括load shader、對應attrib和uniform等)的部分,放入willStartDrawing
- 把update,放入prepareDrawFrame,然後對於計算投影矩陣的部分做一些修改
- 把drawInRect,放入drawEye
幾乎這樣就完成了,不是嗎?
大方的開幹吧!
把範例專案清乾淨的部分,就不多解釋了 - 反正你最後會留下的東西,除了初始化GVR SDK的呼叫,以及一些method的框架,什麼都不用留。然後把之前的練習程式的變數、常數、函示(setupGL、tearDownGL、loadShaders、compileShader、linkProgram、validateProgram)通通都複製過來。
注意當你套用了GVRCardboardView的時候,GVRCardboardView已經有幫你先處理好EAGLContext的部分了,所以你不再需要自己啟動EAGLContext:
[[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2]; // 這個不再需要
接著就是動工戲肉的部分。
把setupGL放入willStartDrawing,這樣程式啟動要開始進行繪製的時候,會去呼叫之前練習時的準備動作。所以willStartDrawing變成只剩下
[self setupGL]
這一行。
接著處理繪製的部分,也就是prepareDrawFrame和drawEye - 這兩個函式的內容,主要是來自練習程式的update和drawInRect。
在這裡我先用我自己的口語說明一下GVRCardboardView的willStartDrawing, prepareDrawFrame, 以及drawEye的用法:
GVRCardboardView在開始繪圖時先呼叫一次willStartDrawing,而且就這麼一次,所以這裡面適合做一次性的準備動作。接著要繪製每個畫面的時候,會先呼叫prepareDrawFrame,然後再分別對左右眼呼叫drawEye。
在先前的練習例子中,我們在update裡面進行"場景的計算",然後drawInRect進行"繪製計算後的結果" - 這個原則在GVRCardboardView可能可以做一下變通:
左右眼都套用同樣資料的,放到prepareDrawFrame裡面。而左右眼不同的,放到drawEye。
當然後面還可以加上一句話
從來都不變動的資料,就不要放到prepareDrawFrame和drawEye裡面了。
於是,我們乾脆把物件的基本位置放到willStartDrawing裡面去,所以它最後變成這樣
上面這裏說明一下:
- 在GVR程式裡面,所有的物件計算,最後都要再套上headTransform的變換,才能把你的座標帶入到顯示所見範圍內。基本上[headTransform headPoseInStartSpace]代表的意義有點像是我們之前的練習的那個projection matrix。我們的物件基礎位置(0, 0, -2),此刻是model view,後面在drawEye的時候才套上projection。
- normalMatrix的計算: 我們在這裡目前不想做移動光源,所以就弄了一個定點光源 - 在初始設定好位置以後就不再變動。但是即使是固定光源,仍然要把這個光源的座標點投射到場景內,所以這裏使用了headTransform把法向量矩陣先做投影。後面在prepareDrawFrame和drawEye就不再計算了。
然後再檢查看看之前練習程式的update做了哪些事: 1.設定modelView,2.設定normal,3.計算rotation。前兩者我們把他放入willStartDrawing了,第三個旋轉的角度我們這裡用不上。於是prepareDrawFrame留下的只剩下清除畫面 - 每次要繪製新的畫面時,把舊的清掉:
(建議這幾行程式,除了顏色你想改變以外,其他的照抄就好)
然後重點是drawEye。這裡只要變通一下就好了。原本我們的練習程式透過時間差異來計算出projection matrix,現在GVRCardboardView可以幫你獲得"某個眼睛的projection matrix",所以你只要取得那個projection matrix,再把它跟物件的基礎位置做計算,然後把他放入到uniform去就好了。
這裡面比較特別的是怎樣計算眼睛的matrix。GVRCardboardView自己有定義了eyeFromHeadMatrix,所以你要先拿你的headPoseInStartSpace,再套上eyeFromHeadMatrix,才能取得真正的眼睛的projection - 你有頭在場景中的相對位置,有眼睛與頭的相對位置,然後你推導出眼睛在場景的相對位置。而這個計算在左右眼有一點點不同,從而做出3D的視覺。
我們仍然是沿用之前的練習,畫出一整排藍色的方塊。
就是這樣沒別的了,如果編譯不過,那就再看看哪邊的剪貼工作沒做好,哪個沒用的變數沒清掉就是了。
執行下去會看到這樣
趕快把手機放到你的Google Cardboard裡面,看看是不是開始有東西要衝到你眼前的感覺了 - 而且,畫面上的方塊會依著你的頭部的轉動而移動的。
然後呢
然後,我猜你會跟我說
這些方塊好像在眼前但是我就是無法靠近,這不VR啊
恩,此刻你戴上Cardboard以後的虛擬世界是這樣: 東西釘在那邊,你佇立在這邊,你東看西看,但是你們兩個都不會靠近對方一步。
先從簡單的開始,讓東西自己動,但是我們不動。我們再次把之前練習程式的定時_rotation請出來。不過我們現在沒使用GLKView了,所以沒有self.timeSinceLastUpdate可用,要自己處理。
所以兩個delegate現在弄成這樣
試試看,它開始水平自轉了。
然後你會發現,它轉到反面的時候,是一片漆黑。因為我們變換了物件,但是光源沒有對應到。所以只好把normal的計算也挪到prepareDrawFrame了。
幾乎跟之前的練習程式的update沒兩樣了。轉動的感覺不錯吧?
如果想移動的話,只要把GLKMatrix4Rotate後面(或前面) 再加上GLKMatrixTranspose就好囉 - 只不過這樣是隨意亂走,仍然不是你互動控制。
我要能自己走
所謂的"走路",其實也就是變換x和z座標(再強調一次,變換y座標的話你會飛),套上GLKMatrix4Transpose就好,但是重點是"要朝向你眼睛看的方向前進",這樣才有fu。怎樣計算你眼睛看的方向呢?
在gvr-ios-sdk的範例裡面,另外有個Stars範例,他就是星際飛行的 - 你坐在太空艙裡面,周圍的星星因為你的前進而向著你的身後飛離。我們把那段拿出來用吧。
其實整個關鍵就是你的headPoseInStartSpace這個矩陣,把他的x,z分量拿出來,加上"行走的速度(假設是定速行走)"的x,z分量,再重組回去成為transpose矩陣就好了。
我們使用一個三維的向量(x, y, z) 來存放目前的總位移:
GLfloat offset[3];
然後在willStartDrawing把他們設為0。
接著在prepareDrawFrame裡面:
每次畫面要刷新,就會取出頭部目前朝向的方向,把他們的x,y,z分量乘上位移的速度,然後更新回去總偏移量。由於我們是想要"往前走",所以是對"當前眼睛看的方向的z軸做變換"。在translate的四乘四矩陣中,第四列的前三個元素就是位移量。
得到offset以後,在drawEye裡面就簡單了,只要在計算modelViewProjection的時候多做一個GLKMatrix4Translate就好了:
好了,現在再戴上你的Google Cardboard,然後繞著你的方塊遊走看看吧!
喂,它自己走不停啊
我們做出來的東西,你只能改變方向,但是不能控制它要走或要停 - 你坐在一個煞車壞掉、油門不能控制的車上,只能控制方向盤...
在CardboardView裡面有個didFireEvent delegate,可以讓你接收使用者觸發的事件。不過很不幸,只有三種事件,而通常能用來真正做互動的,只有kGVRUserEventTrigger - 使用者觸碰了螢幕。
所以就只能在上面動手腳了:
然後在prepareDrawFrame裡,進入計算移動分量的那段程式之前,先判斷這個數值就可以了。
恭喜,我們的第一個VR程式完成了!
範例程式片段: https://github.com/junghao/cardboard-snippets/tree/master/cardboard-ios-1
補充: 關於選擇Google Cardboard Viewer
我手頭上有兩個Cardboard Viewer,左邊那個紙做的,是某個研討會的紀念品,模仿原廠製作的,是真的厚紙板做的。右邊的則是上網買的。
右邊上網買的那個便宜又好看,塑膠製成,有像頭套式耳機的泡棉,還可以綁在頭上,完全就是正規VR裝備的感覺。但是程式寫下去以後發現它根本是廢物... 這是為什麼我特地寫這段的原因。
前面的正文最後一段,說了CardboardView的didFireEvent只接受使用者觸控事件,而iOS 9以後,禁止了模擬觸控事件的API,意思是只接受物理觸碰。而你戴上頭套以後,根本無法碰螢幕,所以整個就變成只能看的廢物了。以前面的練習來說,要進行走動的觸控(這也是目前市面上其他的Cardboard app的使用模式),完全行不通。
相比起來,左邊那個紙板做的頭套,是仿造二代Google Cardboard的(是的,一代也沒有這個機構),它在右上角有一個金屬按鈕,連動到頭套正前方面對螢幕的一個金屬點,因此可以進行觸碰的行為。
缺點就是你手要一直端著它,感覺遜了一點。不過,以實用度來說,他是100分,而右邊那個美麗的頭套,只有0分可以給...