在今年五月的時候,Heresy 有寫了一篇《HTC Vive 開發方案:OpenVR 簡介》,大概介紹過 OpenVR(GitHub)粗略的架構了;當時 OpenVR 的版本還是 1.0.0,Heresy 主要是針對他的幾個模組的功用,做簡單的說明,並沒有認真地去講程式到底怎麼寫。
後來,其實 Heresy 算是有真得下去寫他的程式,也根據自己的需求、搭配 freeglut 重寫出一些小範例(GitHub)了。
到現在過了超過半年,OpenVR 也更新到 1.0.4 了,和 Heresy 剛開始研究時相比,不但 API 有做了一些修改,「hellovr_opengl」的程式內容,也做了相當程度的調整。
而這篇,就以「hellovr_opengl」這個官方範例,來大概紀錄一下怎麼寫 OpenVR 的 OpenGL 程式吧。
首先,「hellovr_opengl」這個官方範例的原始碼就在 OpenVR 資料夾下的「sampleshellovr_opengl」這個資料夾中,原始碼也可以參考 GitHub 上的檔案(連結);而這個範例的顯示結果,就是在一個空間中,有數不盡的方況、很規則地配置在空間中了(上面就是截圖,有左右兩眼的畫面)。
在這個範例中,他使用的視窗管理函式庫是 SDL2(Simple DirectMedia la
他基本上,是把所有的內容包在 CMainApplication 這個類別中,main() 函式僅有簡單的內容而已。
顯示的基本流程
在這個範例裡面,他除了做基本的顯示方法示範之外,其實也還有做簡單的控制器的資料讀取示範;不過 Heresy 在這邊,基本上只會去看顯示的部分而已。
而在整體架構上,他的執行流程大致上是:
- 執行 CMainApplication::BInit() 進行相關的初始化
- 呼叫 vr::VR_Init() 這個函式、來進行 OpenVR 環境的初始化
- 初始化 SDL 的視窗環境
- OpenGL 相關初始化(主要是 CMainApplication::BInitGL())
- 初始化場景相關資源(SetupTexturemaps()、SetupScene())
- 設定雙眼的矩陣資料(SetupCameras())
- 設定繪製雙眼畫面時所需要的 Frame buffer(SetupStereoRenderTargets())
- 設定要顯示在電腦螢幕上的視窗(SetupCompanionWindow())
- 設定 OpenVR 相關裝置模型(SetupRenderModels())
- 初始化 OpenVR 的 Compositor(BInitCompositor())
- 執行 CMainApplication::RunMainLoop()、進入主迴圈
- 處理使用者輸入(HandleInput())
- 繪製畫面(RenderFrame())
- 產生控制器的座標軸線(RenderControllerAxes())
- 套用左右眼的位置資訊、投影矩陣、分別繪製左右眼的場景(RenderStereoTargets())
- 把繪製完的結果、畫到電腦螢幕的視窗上(RenderCompanionWindow())
- 把繪製完的結果、傳送給 OpenVR 顯示
- 更新 OpenVR 裝置的位置資訊(UpdateHMDMatrixPose())
- 呼叫 CMainApplication::Shutdown()、釋放資源
在上面的流程裡面,標記成藍色的部分是要在 OpenVR 環境中顯示時的必要步驟;淺藍色的部分,則是 OpenVR 的控制器部分的程式,在這部分不會去解釋他們。紫色的部分,則是 OpenGL 和 OpenVR 的顯示有關的部分。
初始化
在程式執行時,首先會執行 CMainApplication::BInit()(link)進行相關的初始化。
這邊和 OpenVR 相關的部分,主要就是先透過 vr::VR_Init() 這個函式、來進行 OpenVR 環境的初始化,並取得 vr::IVRSystem 的指標(這邊的變數名稱是 m_pHMD),來做後續的操作。
之後,則是進行諸多和 SDL、OpenGL 相關的設定、以及初始化。最後,則是會再呼叫 BInitGL(),來進行其他細部的初始化。
雙眼的矩陣資料
這邊和 OpenVR 直接相關的,是在 SetupCameras() 這個函式裡面,需要去取得 OpenVR 針對兩眼的投影矩陣(m_mat4ProjectionLeft、m_mat4ProjectionRight)、以及相對於頭部的位置偏移矩陣(m_mat4eyePosLeft、m_mat4eyePosRight)。
而在裡面,實際上是透過 vr::IVRSystem 的 GetProjectionMatrix() 和 GetEyeToHeadTransform() 這兩個函式來取得。
這邊要注意的是,紀錄相對位置的 m_mat4eyePosLeft、m_mat4eyePosRight 這兩個矩陣在透過 GetEyeToHeadTransform() 取出來之後,還經過 invert 過。
由於這四個值(雙眼各兩個)在運作過程中基本上是固定的,所以可以在一開始就取出來、然後記錄下來,之後要繪製的時候,直接拿來用就可以了。
繪製雙眼畫面時所需要的 Frame buffer
在這部分,由於是採用「Render to Texture」的技術,所以需要建立出相關的 frame buffer 與 texture;而這部分的程式,是寫在 SetupStereoRenderTargets() 這個函式裡面,相關的說明可以參考《Tutorial 14 : Render To Texture》這篇文章。
在這邊,他是先透過 vr::IVRSystem 的 GetRecommendedRenderTargetSize() 這個函式, 來取得兩眼的畫面大小;之後則是再呼叫 CreateFrameBuffer() 這個函式、來建立 OpenGL 的 Frame Buffer。
這邊所建立的 OpenGL 資源會記錄在左右眼各一個的 FramebufferDesc 物件中(leftEyeDesc、rightEyeDesc)。
而在 FramebufferDesc 中,m_nRenderFramebufferId 是繪製場景用的 frame buffer,在繪製場景的時候,會把內容畫在它上面;而和他一起的,則還有紀錄深度用的 m_nDepthBufferId、以及紀錄色彩用的 m_nRenderTextureId。
至於剩下的 m_nResolveFramebufferId 和 m_nResolveTextureId 則是在場景畫完後,用來複製 m_nRenderFramebufferId 的內容的 frame buffer,在一切都完成後,要送進 OpenVR 的 OGL texture,就是這邊的 m_nResolveTextureId。
初始化 OpenVR Compositor
繪製
這部分的程式,是寫在 CMainApplication::RenderFrame() 裡面。
其中,它的第一步,是去呼叫 RenderControllerAxes()、取得 OpenVR 的控制器的方向/位置資訊,並根據這些資訊、來產生出他們的座標軸線;不過由於這邊 Heresy 還不打算看控制器的部分,所以就先跳過了。
再來,則是主要繪製場景的 RenderStereoTargets()。這個函式裡面的內容,基本上會針對左右眼、各做一次,所以可以看到裡面的內容大多是重複兩次的。
如果只看左眼的話,程式的內容如下:
glEnable( GL_MULTISAMPLE ); // Left Eye glBindFramebuffer( GL_FRAMEBUFFER, leftEyeDesc.m_nRenderFramebufferId ); glViewport(0, 0, m_nRenderWidth, m_nRenderHeight ); RenderScene( vr::Eye_Left ); glBindFramebuffer( GL_FRAMEBUFFER, 0 ); glDisable( GL_MULTISAMPLE ); glBindFramebuffer(GL_READ_FRAMEBUFFER, leftEyeDesc.m_nRenderFramebufferId); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, leftEyeDesc.m_nResolveFramebufferId ); glBlitFramebuffer( 0, 0, m_nRenderWidth, m_nRenderHeight, 0, 0, m_nRenderWidth, m_nRenderHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR ); glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0 );
繪製場景
其中,RenderScene() 就是正常繪製場景的函式,他和一般直接寫 OpenGL 的場景繪製程式最大的差異,就是需要考慮現在在畫的場景是左眼的還是右眼的;因為兩眼的攝影機位置、和投影矩陣都略有不同,所以在繪製時必須知道對應的投影矩陣以及攝影機位置。
在這邊,他是透過 GetCurrentViewProjectionMatrix() 這個函式來計算;而以左眼的狀況來說,它的內容就是:
m_mat4ProjectionLeft * m_mat4eyePosLeft * m_mat4HMDPose
其中,m_mat4ProjectionLeft 和 m_mat4eyePosLeft 都是之前在呼叫 SetupCameras() 時就取得的資料;而最後的 m_mat4HMDPose 則是每次繪製後、會透過 UpdateHMDMatrixPose() 這個函式來更新。
複製 Frame Buffer
至於其他和傳統 OpenGL 繪製的差別,就是在呼叫 RenderScene() 前,要先呼叫 glBindFramebuffer()、來指定 render target 是之前產生的 frame buffer(m_nRenderFramebufferId),這樣場景在繪製的時後,才會把內容畫在之前產生的 texture(m_nRenderTextureId)上。
而等到場景畫完了之後,則是要再透過 glBlitFramebuffer() 這個函式,把資料從 m_nRenderFramebufferId 複製到 m_nResolveFramebufferId。
繪製到螢幕的畫面
這邊的內容是寫在 RenderCompanionWindow() 裡,基本上就把左右兩眼繪製好、並複製出來的 texture(m_nResolveTextureId),畫到螢幕上。
而這部分的程式基本上算是非必需的,甚至是可以直接跳過的;如果沒有做的話,基本上也只是視窗上看不到正在顯示的內容,實際上對於 VR 顯示是沒有影響的。
把資料傳給 OpenVR
在把 frame buffer 複製出來後,接下來就可以把 frame buufer 所使用的 texture(m_nResolveTextureId)、傳送給 OpenVR,讓他顯示在頭戴顯示器上了。
這邊要呼叫的,是 vr::IVRCompositor::Submit() 這個函式,分別把兩眼的 texture、傳送給 OpenVR;其程式寫法如下:
vr::Texture_t leftEyeTexture = { (void*)leftEyeDesc.m_nResolveTextureId, vr::API_OpenGL, vr::ColorSpace_Gamma }; vr::VRCompositor()->Submit(vr::Eye_Left, &leftEyeTexture );
這邊呼叫 vr::VRCompositor() 主要是先取得 OpenVR 內部的 IVRCompositor 物件指標、然後再來呼叫他的 Submit() 函式。
使用方法,基本上就是先把 texture id 在內的資料、封包成 vr::Texture_t 的格式,然後在 Submit() 傳給 OpenVR。
而當 OpenVR 收到左右兩眼的資料後,就會把它顯示在頭戴顯示器裡了。
更新 OpenVR 裝置的位置資訊
在 RenderFrame() 中,主要在繪製的程式,就是上面的部分了;而在這個函式的後段,則還有一些結束繪製、swap buffer 的程式。這部分,有的程式感覺是在用很髒的程式、試著解決一些問題,看來是必須要自己看看、進行試驗了。
而在都結束後,則是會呼叫 UpdateHMDMatrixPose()、來更新 OpenVR 相關裝置的位置資訊;而裡面取則對應的矩陣的方法,是:
vr::VRCompositor()->WaitGetPoses(
m_rTrackedDevicePose, vr::k_unMaxTrackedDeviceCount,
NULL, 0 );
這邊是透過 vr::IVRCompositor::WaitGetPoses() 這個函式,去取得所有裝置的資訊。其中執行的參數 m_rTrackedDevicePose 是一個 vr::TrackedDevicePose_t 的陣列,其大小為 vr::k_unMaxTrackedDeviceCount,在執行 WaitGetPoses() 後,OpenVR 會把每個裝置對應的資料,填到 m_rTrackedDevicePose 裡面。
在 m_rTrackedDevicePose 中,除了紀錄該裝置位置與方向資訊的 mDeviceToAbsoluteTracking 外,也還有紀錄速度的 vVelocity、角速度的 vAngularVelocity,以及他的連線和追蹤狀態的變數(bDeviceIsConnected、bPoseIsValid)。
而在執行 WaitGetPoses() 時,除了前面兩個參數 m_rTrackedDevicePose 和 vr::k_unMaxTrackedDeviceCount 外,這邊給的第三個、第四個參數是 NULL 和 0;這部分基本上是 Heresy 不確定意義的地方了…
根據函式的介面來看,前面兩個參數是「render pose」、應該是用來繪製用的位置資訊;而後面兩個參數則是「game pose」,理論上應該是遊戲中用的位置資訊。但是這兩者之間的差異,Heresy 並沒有找到,所以也就不知道為什麼要特別弄個「game pose」出來了… @@
在 UpdateHMDMatrixPose() 中,在取得 m_rTrackedDevicePose 的資料後,會先透過一個迴圈、去處理所有裝置的位置資訊;裡面在做的事情,主要是透過 ConvertSteamVRMatrixToMatrix4() 這個函式,把 mDeviceToAbsoluteTracking 這個矩陣轉換成這個範例裡面、自己定義的 Matrix4 的格式,然後儲存在 m_rmat4DevicePose 這個陣列中。
而如果是使用其他的矩陣計算函式庫(例如 glm)的話,也可以轉換成自己需要的格式。
這個函式最後面的,基本上則是去檢查頭戴顯示器(其索引值是 vr::k_unTrackedDeviceIndex_Hmd)的姿勢是否有效,如果是有效的話,就會把它儲存到 m_mat4HMDPose 這個變數裡、然後再做矩陣的 invert 計算;其程式碼如下:
if ( m_rTrackedDevicePose[vr::k_unTrackedDeviceIndex_Hmd].bPoseIsValid ) { m_mat4HMDPose = m_rmat4DevicePose[vr::k_unTrackedDeviceIndex_Hmd]; m_mat4HMDPose.invert(); }
在執行完之後,在下一次繪製場景的時候,GetCurrentViewProjectionMatrix() 就可以針對新的頭戴式顯示器的位置資訊(m_mat4HMDPose)、來算出兩眼個別的投影矩陣了~
不過,理論上這個步驟,應該是要在繪製之前做才對?但是 OpenVR 的範例卻是放在最後面,這也代表了,再畫第一個畫面的時候,畫面應該會是錯的(因為根本不知道頭在哪)!不過由於這個畫面的維持時間極短,所以應該不會有問題就是了。
關閉 OpenVR 環境
這部分是寫在 CMainApplication::Shutdown() 裡面。和 OpenVR 直接相關的部分,基本上就只有一行:
vr::VR_Shutdown();
當然啦,剛剛產生的那些 OpenGL 相關的資源也需要再釋放掉,不過和 OpenVR 直接相關的,真的就只有這行了。
而以範例程式來看,透過 vr::VR_Init() 這個函式取得的 vr::IVRSystem 指標(m_pHMD),似乎是不需要特別去刪除,如果怕之後被誤用,只要再把他設定成 NULL 就可以了。
和顯相關的程式,大概就是這樣了。其實整體來看,OpenVR 的部分相當簡單,困難的主要還是 OpenGL 的部分啊…
而整個流程如果更簡化來看的話,基本上就是:
- 初始化:vr::VR_Init()
- 主迴圈、繪製
- 更新位置:vr::VRCompositor()->WaitGetPoses()
- 繪製左眼場景到指定的 Frame Buffer
- 繪製右眼場景到指定的 Frame Buffer
- 將左右眼的 Frame Buffer 資料複製出來
- 將結果的 texture 傳送給 OpenVR 顯示:vr::VRCompositor()->Submit()
- 結束:vr::VR_Shutdown()
其中,Heresy 比較訝異的是,在當初在看的時候,覺得最複雜的「distortion」的部分,其實只是把東西畫在螢幕的視窗上,目前看來根本是非必要性的?而在早期的 OpenVR 範例裡面,他還特別寫 shader、把 RGB 三個顏色分開計算,但是現在的範例卻已經沒有做這個處理了?