前言
前幾章我們簡單介紹了一下如何通過Google提供的SDK來展示全景圖和VR視頻。這章節(jié)我們來介紹如何手動渲染VR場景,主要涉及兩個重要的類:GvrActivity和GvrView。
GvrActivity
先依賴庫
implementation 'com.google.vr:sdk-base:1.180.0'
GvrActivity提供了VR相關的一些細節(jié),通過代碼可以看到,它持有一個GvrView對象,但是需要我們手動set才可以,GvrActivity處理了一些相關事件以及管理GvrView的生命周期等等。
所以我們需要先創(chuàng)建一個繼承GvrActivity,然后通過setGvrView(GvrView gvrView)
函數(shù)將GvrView對象賦給它,這樣有些事件,比如GvrView的生命周期管理等就不需要我們再操心了。
GvrView
GvrView才是用來渲染的,我們在這個View上渲染VR場景或組件。它有兩個接口,如下:
public interface StereoRenderer {
@UsedByNative
void onNewFrame(HeadTransform headTransform);
@UsedByNative
void onDrawEye(Eye eye);
@UsedByNative
void onFinishFrame(Viewport viewport);
void onSurfaceChanged(int width, int height);
void onSurfaceCreated(EGLConfig config);
void onRendererShutdown();
}
public interface Renderer {
@UsedByNative
void onDrawFrame(HeadTransform headTransform, Eye leftEye, Eye rightEye);
@UsedByNative
void onFinishFrame(Viewport viewport);
void onSurfaceChanged(int width, int height);
void onSurfaceCreated(EGLConfig config);
void onRendererShutdown();
}
這里注意幾個重要的函數(shù):
- onSurfaceCreated:這里我們可以進行一些初始化操作。比如初始化材料,創(chuàng)建Buffer,加載著色器等等
- onNewFrame:StereoRenderer獨有的,在繪制一幀畫面前做一些準備工作。
- onDrawEye/onDrawFrame:在這里我們進行繪制工作。
下面我們用一個簡單的demo來看看GvrActivity和GvrView如何使用。
簡單demo
源碼如下:
import android.os.Bundle
import com.google.vr.sdk.base.*
import com.huichongzi.vrardemo.databinding.ActivityGvrDemoBinding
import javax.microedition.khronos.egl.EGLConfig
import android.opengl.GLES30
class GvrDemoActivity : GvrActivity(), GvrView.StereoRenderer {
private var _binding : ActivityGvrDemoBinding? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = ActivityGvrDemoBinding.inflate(layoutInflater)
setContentView(_binding?.root)
//初始化
gvrView = _binding?.gvrView
gvrView.setEGLConfigChooser(8,8,8,8,16,8)
gvrView.setRenderer(this)
gvrView.setTransitionViewEnabled(true)
gvrView.enableCardboardTriggerEmulation()
}
override fun onNewFrame(headTransform: HeadTransform?) {
}
override fun onDrawEye(eye: Eye?) {
GLES30.glEnable(GLES30.GL_DEPTH_TEST) //啟用深度測試,自動隱藏被遮住的材料
//清除緩沖區(qū),即將緩沖區(qū)設置為glClearColor設定的顏色
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT or GLES30.GL_DEPTH_BUFFER_BIT)
}
override fun onFinishFrame(viewport: Viewport?) {
}
override fun onSurfaceChanged(width: Int, height: Int) {
}
override fun onSurfaceCreated(config: EGLConfig?) {
//設置清除顏色,即背景色
GLES30.glClearColor(0f,0f,1f,1f)
}
override fun onRendererShutdown() {
}
override fun onBackPressed() {
finish()
}
}
布局很簡單,只有一個GvrView。
我們這個Activity實現(xiàn)了GvrView.StereoRenderer
接口。在onCreate
中將GvrView實例set一下,并且通過setRenderer
函數(shù)將GvrView.StereoRenderer
關聯(lián)GvrView。然后我們在GvrView.StereoRenderer
的對應函數(shù)中實現(xiàn)渲染即可。
在這個demo中,我們只是繪制了一個藍色背景色。在onSurfaceCreated
中設置清除顏色(背景色),然后在onDrawEye
中清除緩沖區(qū)(用剛才設置的顏色),這樣就繪制了藍色的背景色,運行結果如下:
這樣我們就可以看到背景色了,下一步我們就可以繪制一些物體。
OpenGL繪制三角形
我們先來復習一下如何用OpenGL來繪制圖形。OpenGL繪制的基本單元有點、線和三角形,其他所有的圖形實際上都是由三角形組成的。所以我們來看看如何用OpenGL繪制三角形。
由于OpenGL相關知識過于龐大,這里就不詳細解釋太多,大家自行查閱OpenGL資料。
著色器
首先我們需要創(chuàng)建頂點著色器(Vrtex Shader)和片段著色器(Fragment Shader)。著色器是用來實現(xiàn)圖像渲染的,用來替代固定渲染管線的可編輯程序,在整個繪制過程中發(fā)揮著重要的作用,如圖:
其中頂點著色器主要就是處理頂點數(shù)據(jù)的,比如位置、顏色;片段著色器就是處理每個片段的顏色和屬性。著色器相關知識一兩句說不清楚,大家自行查閱資料吧。
其實著色器就是一段程序代碼,所以我們可以直接用字符串。但是在Android Studio中提供了著色器類型文件,即glsl文件。如果想創(chuàng)建這樣的文件,首先需要為Android Studio安裝一個GLSL Support插件
然后我們在創(chuàng)建新文件的菜單中就會發(fā)現(xiàn)多出一個GLSL Shader類型
我們在raw目錄下創(chuàng)建一個頂點著色器vertex_simple_shade.glsl
attribute vec4 vPosition;
void main() {
gl_Position = vPosition;
gl_PointSize = 10.0;
}
這里簡單的將輸入的頂點坐標vPosition拷貝給gl_Position,并設置頂點直徑為10(繪制點的時候會用到)。
再創(chuàng)建一個片段著色器fragment_simple_shade.glsl
precision mediump float;
void main() {
gl_FragColor = vec4(1.0,1.0,1.0,1.0);
}
只是設定顏色為白色而已。關于著色器語法大家同樣查閱資料吧。
加載著色器
首先創(chuàng)建編譯著色器,的到著色器id,代碼如下:
fun compileShader(type: Int, code : String) : Int{
//創(chuàng)建一個著色器
val id = GLES20.glCreateShader(type)
if(id != 0){
//載入著色器源碼
GLES20.glShaderSource(id, code)
//編譯著色器
GLES20.glCompileShader(id)
//檢查著色器是否編譯成功
val status = IntArray(1)
GLES20.glGetShaderiv(id, GLES20.GL_COMPILE_STATUS, status, 0)
if(status[0] == 0){
//創(chuàng)建失敗
GLES20.glDeleteShader(id)
return 0
}
return id
}
else {
return 0
}
}
這里
code就是將上面我們創(chuàng)建的glsl文件讀取為字符串即可,所以我們前面說著色器程序直接使用字符串也可以。
type就是著色器類型,頂點著色器
GLES20.GL_VERTEX_SHADER
和片段著色器GLES20.GL_FRAGMENT_SHADER
再得到頂點和片段著色器的id后,下一步將它們鏈接到程序中,代碼如下:
fun linkProgram(vertexShaderId : Int, fragmentShaderId : Int) : Int{
//創(chuàng)建一個GLES程序
val id = GLES20.glCreateProgram()
if(id != 0){
//將頂點和片元著色器加入程序
GLES20.glAttachShader(id, vertexShaderId)
GLES20.glAttachShader(id, fragmentShaderId)
//鏈接著色器程序
GLES20.glLinkProgram(id)
//檢查是否鏈接成功
val status = IntArray(1)
GLES20.glGetProgramiv(id, GLES20.GL_LINK_STATUS, status, 0)
if(status[0] == 0){
GLES20.glDeleteProgram(id)
return 0
}
return id
}
else{
return 0
}
}
這樣我們就得到了一個programId,最后通過GLES20.glUseProgram(programId)
將其使用起來即可,后面我們會看到在哪一步來處理這些。
繪制
準備好著色器后,我們就可以著手繪制三角形了,在頁面中添加一個GLSurfaceView,然后我們準備三個頂點坐標,如下
val triangleCoords = floatArrayOf(0.5f, 0.5f, 0.0f, // top
-0.5f, -0.5f, 0.0f, // bottom left
0.5f, -0.5f, 0.0f // bottom right
)
OpenGL的坐標系是屏幕中心是(0,0),上下左右的最大值都是1,所以在手機上x軸和y軸上相同數(shù)值對應的實際長度并不一樣,所以上面數(shù)值上雖然看著是等腰三角形,但是實際上并不是。
然后為GLSurfaceView設置Radnerer,并繪制三角形,代碼如下:
setRenderer(object : GLSurfaceView.Renderer{
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
//設置背景顏色
GLES20.glClearColor(0f,0f,1f,1f)
//初始化頂點坐標緩沖
vertexBuffer = ByteBuffer.allocateDirect(triangleCoords.size * 4).order(
ByteOrder.nativeOrder()).asFloatBuffer()
vertexBuffer.put(triangleCoords)
vertexBuffer.position(0)
activity?.apply {
//加載著色器
val vertexShaderId = ShaderUtils.compileVertexShader(R.raw.vertex_simple_shade, this)
val fragmentShaderId = ShaderUtils.compileFragmentShader(R.raw.fragment_simple_shade, this)
program = ShaderUtils.linkProgram(vertexShaderId, fragmentShaderId)
GLES20.glUseProgram(program)
}
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
//設置視圖窗口
GLES20.glViewport(0,0,width,height)
}
override fun onDrawFrame(gl: GL10?) {
//繪制背景
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
val attr = GLES20.glGetAttribLocation(program, "vPosition")
//準備坐標數(shù)據(jù)
GLES20.glVertexAttribPointer(attr, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer)
//啟用頂點位置句柄,注意這里是屬性0,對應著頂點著色器中的layout (location = 0)
GLES20.glEnableVertexAttribArray(attr)
//繪制三角形
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
//禁用頂點位置句柄
GLES20.glDisableVertexAttribArray(attr)
}
})
在onSurfaceCreated初始化頂點數(shù)據(jù)緩沖,然后加載著色器。
在onDrawFrame中繪制三角形,注意這里先通過GLES20.glGetAttribLocation(program, "vPosition")
獲取我們在頂點著色器中定義的vPosition屬性,然后將頂點坐標傳入,最后通過GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
來繪制三角形。
有關OpenGL各個函數(shù)這里同樣不詳細介紹了,這里只重點說說glDrawArrays這個函數(shù)。它有三個參數(shù):
- mode:繪制模式。這里有很多中,比如我們用的
GLES20.GL_TRIANGLES
就是三角形;還有點GLES20.GL_POINTS
,繪制出來就是三個點;還有閉環(huán)的線GLES20.GL_LINE_LOOP
,繪制出來就是三角形的三條邊;還有非閉環(huán)的線GLES20.GL_LINE_STRIP
等等 - first:從哪個點開始
- count:繪制點的數(shù)量
繪制結果就不上圖了,就是一個三角形。
這樣我們回顧了如何繪制三角形,接下來看看如果在GvrView中繪制一個三角形。
繪制三角形
在GvrView中繪制三角形與在上面類似,同樣需要兩個著色器,我們先復用上面兩個看看是什么效果。
然后在onSurfaceCreated中加載著色器,在onDrawEye中繪制三角形即可,代碼如下:
override fun onSurfaceCreated(config: EGLConfig?) {
GLES20.glClearColor(0f,0f,1f,1f)
//初始化頂點坐標緩沖
vertexBuffer = ByteBuffer.allocateDirect(triangleCoords.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer()
vertexBuffer.put(triangleCoords)
vertexBuffer.position(0)
//加載著色器
val vertexShaderId = ShaderUtils.compileVertexShader(R.raw.vertex_simple_shade, this)
val fragmentShaderId = ShaderUtils.compileFragmentShader(R.raw.fragment_simple_shade, this)
objProgram = ShaderUtils.linkProgram(vertexShaderId, fragmentShaderId)
objectPositionParam = GLES20.glGetAttribLocation(objProgram, "vPosition")
}
override fun onDrawEye(eye: Eye) {
GLES20.glEnable(GLES20.GL_DEPTH_TEST) //啟用深度測試,自動隱藏被遮住的材料
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
GLES20.glUseProgram(objProgram)
//啟用頂點位置句柄
GLES20.glEnableVertexAttribArray(objectPositionParam)
//準備坐標數(shù)據(jù)
GLES20.glVertexAttribPointer(objectPositionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer)
//繪制物體
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
//禁用頂點位置句柄
GLES20.glDisableVertexAttribArray(objectPositionParam)
}
可以看到在左右分別繪制了一個三角形,所以說在GvrView中左右其實是兩個區(qū)域,而圖形會在兩個區(qū)域都進行繪制。同時我們也看到上面繪制的圖形是無法變動的,沒有隨著設備的移動而變化,也就是說只是單純的繪制在屏幕上,并沒有繪制在VR空間中。下一步我們來看看如果讓它動起來。
動起來
如果圖形不動,那么就失去了VR的意義,而如何才能讓圖形動起來?這就用到了我們之前提到的著色器,我們知道在頂點著色器中可以對頂點坐標進行轉換,正是通過它我們可以實現(xiàn)圖形的各種移動或變形。
著色器
先來重新創(chuàng)建一個頂點著色器gvr_vertex_shade.glsl
uniform mat4 u_MVP; //外部輸入,4x4矩陣
attribute vec4 vPosition;
void main() {
gl_Position = u_MVP * vPosition;
}
這里除了定義了一個輸入vPosition,還定義了一個Uniform類型的輸入u_MVP。這里先簡單說一下Uniform和Attribute,在著色器中有三種類型變量
- Uniform:是全局的,在頂點和片段著色器中都可以訪問,一般用來表示轉換矩陣、顏色、材質(zhì)等
- Attribute:只在頂點著色器中使用,一般用來表示頂點的一些數(shù)據(jù),如坐標、頂點顏色等等;
- Varying:除它們倆還有一個Varying,它是用來在頂點著色器和片段著色器間傳遞數(shù)據(jù)的,一般在頂點著色器中修改它的值,在片段著色器中使用。
所以在我們的代碼里u_MVP就是轉換矩陣,通過它與vPosition相乘來得到新的坐標,這樣我們通過修改u_MVP就可以實現(xiàn)渲染位置的變動。
片段著色器不變,保持原樣即可。
初始化
那么接下來就是u_MVP如何得到?我們來修改一下GvrDemoActivity的代碼。
首先設置相機的位置,我們在onNewFrame
中來做這部分操作
override fun onNewFrame(headTransform: HeadTransform?) {
//設置相機位置
Matrix.setLookAtM(camera, 0, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f)
}
然后在onSurfaceCreated
中做一些初始化的操作,如下:
override fun onSurfaceCreated(config: EGLConfig?) {
GLES20.glClearColor(0f,0f,1f,1f)
//初始化頂點坐標緩沖
vertexBuffer = ByteBuffer.allocateDirect(triangleCoords.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer()
vertexBuffer.put(triangleCoords)
vertexBuffer.position(0)
//加載著色器
val vertexShaderId = ShaderUtils.compileVertexShader(R.raw.gvr_vertex_shade, this)
val fragmentShaderId = ShaderUtils.compileFragmentShader(R.raw.fragment_simple_shade, this)
objProgram = ShaderUtils.linkProgram(vertexShaderId, fragmentShaderId)
//獲取u_MVP這個輸入量
objectModelViewProjectionParam = GLES20.glGetUniformLocation(objProgram, "u_MVP")
objectPositionParam = GLES20.glGetAttribLocation(objProgram, "vPosition")
//初始化modelTarget
Matrix.setIdentityM(modelTarget, 0)
Matrix.translateM(modelTarget, 0,0.0f, 0.0f, -3.0f)
}
在這里主要做以下操作:
- 初始化緩沖
- 加載著色器
- 獲取著色器屬性:這里可以看到通過
glGetUniformLocation
和glGetAttribLocation
分別獲取兩種不同類型的屬性。 - 初始化物體的世界坐標轉換矩陣modelTarget
這里來說一下modelTarget,要理解這部分以及下面計算繪制的部分,需要你先了解OpenGL中各種坐標系的知識,因為這部分知識也很龐大,我這里簡單說一下,大家想詳細了解可以自行查詢相關資料。
坐標系
在OpenGL中存在幾種坐標系:
局部坐標系:物體的局部空間,比如上面我們定義的頂點坐標triangleCoords,它是物體的本地坐標
世界坐標系:物體在三維空間中的坐標。我們需要將物體的各個頂點坐標轉換到三維空間的坐標,上面的modelTarget就是這個轉換矩陣,將頂點坐標與它相乘就會得到世界坐標系中的位置
視圖坐標系:以觀察點為中心的坐標系。我們知道觀察的位置不同看到的景象也是不同的,所以需要將世界坐標轉換成視圖坐標,這個后面會處理
投影坐標系:以上都是針對三維的頂點坐標進行轉換,但是最終呈現(xiàn)在屏幕上還是一個二維平面,所以需要一個投影過程,所以需要將視圖坐標轉換成投影坐標
屏幕坐標系:最后將投影坐標轉成屏幕坐標并顯示出來,這部分我們不需要自己處理。
所以可以看到物體的頂點通過以上4個坐標系(局部坐標系、世界坐標系、視圖坐標系和投影坐標系)和三個變換矩陣,得到了最終坐標才進行繪制,這部分處理如圖:
計算繪制
簡單復習了OpenGL的坐標系知識后,我來看看最后一步。
最后在onDrawEye
中計算回繪制物體,代碼如下:
override fun onDrawEye(eye: Eye) {
GLES20.glEnable(GLES20.GL_DEPTH_TEST)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
//將眼睛的位置變化應用到相機
Matrix.multiplyMM(view, 0, eye.eyeView, 0, camera, 0)
val perspective = eye.getPerspective(Z_NEAR, Z_FAR)
Matrix.multiplyMM(modelView, 0, view, 0, modelTarget, 0)
Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0)
//將modelViewProjection輸入頂點著色器(u_MVP)
GLES20.glUseProgram(objProgram)
GLES20.glUniformMatrix4fv(objectModelViewProjectionParam, 1, false, modelViewProjection, 0)
//啟用頂點位置句柄
GLES20.glEnableVertexAttribArray(objectPositionParam)
//準備坐標數(shù)據(jù)
GLES20.glVertexAttribPointer(objectPositionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer)
//繪制物體
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
//禁用頂點位置句柄
GLES20.glDisableVertexAttribArray(objectPositionParam)
}
注意這個函數(shù)有一個參數(shù)eye,這里面包含著當前視線的一些信息,比如當我們移動手機的時候,我們的視線是變化的,onDrawEye中的eye中的屬性也是實時變化的。這樣我們通過這種變化來調(diào)整繪制實現(xiàn)場景的移動。
首先通過相機和眼睛的位置,得到一個世界坐標系到眼坐標系(視圖坐標系)的轉換矩陣view;然后將物體的世界坐標轉換成眼坐標modelView;在通過投影矩陣轉換成投影坐標modelViewProjection;最后將這個最終的轉換矩陣傳入著色器的u_MVP,這樣在著色器中頂點坐標通過這一系列轉換就成功的計算成最終坐標,這樣就可以進行繪制的。
計算完成后進行繪制即可。
可以看到,我們在VR世界中成功繪制了一個三角形,隨著手機(視線)的移動景象也有了變化。