OpenVR 搭配 OpenGL 的顯示方法

| | 0 Comments| 11:00
Categories:

在今年五月的時候,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 layer、官網),不過 Heresy 不打算針對這邊多加著墨。

他基本上,是把所有的內容包在 CMainApplication 這個類別中,main() 函式僅有簡單的內容而已。


顯示的基本流程

在這個範例裡面,他除了做基本的顯示方法示範之外,其實也還有做簡單的控制器的資料讀取示範;不過 Heresy 在這邊,基本上只會去看顯示的部分而已。

而在整體架構上,他的執行流程大致上是:

  1. 執行 CMainApplication::BInit() 進行相關的初始化
    1. 呼叫 vr::VR_Init() 這個函式、來進行 OpenVR 環境的初始化
    2. 初始化 SDL 的視窗環境
    3. OpenGL 相關初始化(主要是 CMainApplication::BInitGL()
      1. 初始化場景相關資源(SetupTexturemaps()SetupScene()
      2. 設定雙眼的矩陣資料(SetupCameras()
      3. 設定繪製雙眼畫面時所需要的 Frame buffer(SetupStereoRenderTargets()
      4. 設定要顯示在電腦螢幕上的視窗(SetupCompanionWindow()
      5. 設定 OpenVR 相關裝置模型(SetupRenderModels()
    4. 初始化 OpenVR 的 Compositor(BInitCompositor()
  2. 執行 CMainApplication::RunMainLoop()、進入主迴圈
    1. 處理使用者輸入(HandleInput()
    2. 繪製畫面(RenderFrame()
      1. 產生控制器的座標軸線(RenderControllerAxes()) 
      2. 套用左右眼的位置資訊、投影矩陣、分別繪製左右眼的場景(RenderStereoTargets()
      3. 把繪製完的結果、畫到電腦螢幕的視窗上(RenderCompanionWindow()
      4. 把繪製完的結果、傳送給 OpenVR 顯示
      5. 更新 OpenVR 裝置的位置資訊(UpdateHMDMatrixPose() 
  3. 呼叫 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_mat4ProjectionLeftm_mat4ProjectionRight)、以及相對於頭部的位置偏移矩陣(m_mat4eyePosLeftm_mat4eyePosRight)。

而在裡面,實際上是透過 vr::IVRSystemGetProjectionMatrix()GetEyeToHeadTransform() 這兩個函式來取得。

這邊要注意的是,紀錄相對位置的 m_mat4eyePosLeftm_mat4eyePosRight 這兩個矩陣在透過 GetEyeToHeadTransform() 取出來之後,還經過 invert 過。

由於這四個值(雙眼各兩個)在運作過程中基本上是固定的,所以可以在一開始就取出來、然後記錄下來,之後要繪製的時候,直接拿來用就可以了。

繪製雙眼畫面時所需要的 Frame buffer

在這部分,由於是採用「Render to Texture」的技術,所以需要建立出相關的 frame buffer 與 texture;而這部分的程式,是寫在 SetupStereoRenderTargets() 這個函式裡面,相關的說明可以參考《Tutorial 14 : Render To Texture》這篇文章。

在這邊,他是先透過 vr::IVRSystemGetRecommendedRenderTargetSize() 這個函式, 來取得兩眼的畫面大小;之後則是再呼叫 CreateFrameBuffer() 這個函式、來建立 OpenGL 的 Frame Buffer。

這邊所建立的 OpenGL 資源會記錄在左右眼各一個的 FramebufferDesc 物件中(leftEyeDescrightEyeDesc)。

而在 FramebufferDesc 中,m_nRenderFramebufferId 是繪製場景用的 frame buffer,在繪製場景的時候,會把內容畫在它上面;而和他一起的,則還有紀錄深度用的 m_nDepthBufferId、以及紀錄色彩用的 m_nRenderTextureId

至於剩下的 m_nResolveFramebufferIdm_nResolveTextureId 則是在場景畫完後,用來複製 m_nRenderFramebufferId 的內容的 frame buffer,在一切都完成後,要送進 OpenVR 的 OGL texture,就是這邊的 m_nResolveTextureId

初始化 OpenVR Compositor

這部分是 BInitCompositor()。不過老實說,這邊與其說是初始化,倒不如說只是先去呼叫一次 vr::VRCompositor()、確認他是否可以正確回傳 IVRCompositor 的物件指標而已。

繪製

這部分的程式,是寫在 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_mat4ProjectionLeftm_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,以及他的連線和追蹤狀態的變數(bDeviceIsConnectedbPoseIsValid)。

而在執行 WaitGetPoses() 時,除了前面兩個參數 m_rTrackedDevicePosevr::k_unMaxTrackedDeviceCount 外,這邊給的第三個、第四個參數是 NULL0;這部分基本上是 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 三個顏色分開計算,但是現在的範例卻已經沒有做這個處理了?

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *