前言
前几章我们简单介绍了一下如何通过Google提供的SDK来展示全景图和VR视频。这章节我们来介绍如何手动渲染VR场景,主要涉及两个重要的类:GvrActivity和GvrView。
GvrActivity
先依赖库
implementation 'com.google.vr:sdk-base:1.180.0'
GvrActivity提供了VR相关的一些细节,通过代码可以看到,它持有一个GvrView对象,但是需要我们手动set才可以,GvrActivity处理了一些相关事件以及管理GvrView的生命周期等等。
所以我们需要先创建一个继承GvrActivity,然后通过setGvrView(GvrView gvrView)
函数将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();
}
这里注意几个重要的函数:
- onSurfaceCreated:这里我们可以进行一些初始化操作。比如初始化材料,创建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) //启用深度测试,自动隐藏被遮住的材料
//清除缓冲区,即将缓冲区设置为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实现了GvrView.StereoRenderer
接口。在onCreate
中将GvrView实例set一下,并且通过setRenderer
函数将GvrView.StereoRenderer
关联GvrView。然后我们在GvrView.StereoRenderer
的对应函数中实现渲染即可。
在这个demo中,我们只是绘制了一个蓝色背景色。在onSurfaceCreated
中设置清除颜色(背景色),然后在onDrawEye
中清除缓冲区(用刚才设置的颜色),这样就绘制了蓝色的背景色,运行结果如下:
这样我们就可以看到背景色了,下一步我们就可以绘制一些物体。
OpenGL绘制三角形
我们先来复习一下如何用OpenGL来绘制图形。OpenGL绘制的基本单元有点、线和三角形,其他所有的图形实际上都是由三角形组成的。所以我们来看看如何用OpenGL绘制三角形。
由于OpenGL相关知识过于庞大,这里就不详细解释太多,大家自行查阅OpenGL资料。
着色器
首先我们需要创建顶点着色器(Vrtex Shader)和片段着色器(Fragment Shader)。着色器是用来实现图像渲染的,用来替代固定渲染管线的可编辑程序,在整个绘制过程中发挥着重要的作用,如图:
其中顶点着色器主要就是处理顶点数据的,比如位置、颜色;片段着色器就是处理每个片段的颜色和属性。着色器相关知识一两句说不清楚,大家自行查阅资料吧。
其实着色器就是一段程序代码,所以我们可以直接用字符串。但是在Android Studio中提供了着色器类型文件,即glsl文件。如果想创建这样的文件,首先需要为Android Studio安装一个GLSL Support插件
然后我们在创建新文件的菜单中就会发现多出一个GLSL Shader类型
我们在raw目录下创建一个顶点着色器vertex_simple_shade.glsl
attribute vec4 vPosition;
void main() {
gl_Position = vPosition;
gl_PointSize = 10.0;
}
这里简单的将输入的顶点坐标vPosition拷贝给gl_Position,并设置顶点直径为10(绘制点的时候会用到)。
再创建一个片段着色器fragment_simple_shade.glsl
precision mediump float;
void main() {
gl_FragColor = vec4(1.0,1.0,1.0,1.0);
}
只是设定颜色为白色而已。关于着色器语法大家同样查阅资料吧。
加载着色器
首先创建编译着色器,的到着色器id,代码如下:
fun compileShader(type: Int, code : String) : Int{
//创建一个着色器
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){
//创建失败
GLES20.glDeleteShader(id)
return 0
}
return id
}
else {
return 0
}
}
这里
code就是将上面我们创建的glsl文件读取为字符串即可,所以我们前面说着色器程序直接使用字符串也可以。
type就是着色器类型,顶点着色器
GLES20.GL_VERTEX_SHADER
和片段着色器GLES20.GL_FRAGMENT_SHADER
再得到顶点和片段着色器的id后,下一步将它们链接到程序中,代码如下:
fun linkProgram(vertexShaderId : Int, fragmentShaderId : Int) : Int{
//创建一个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轴上相同数值对应的实际长度并不一样,所以上面数值上虽然看着是等腰三角形,但是实际上并不是。
然后为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")
//准备坐标数据
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初始化顶点数据缓冲,然后加载着色器。
在onDrawFrame中绘制三角形,注意这里先通过GLES20.glGetAttribLocation(program, "vPosition")
获取我们在顶点着色器中定义的vPosition属性,然后将顶点坐标传入,最后通过GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
来绘制三角形。
有关OpenGL各个函数这里同样不详细介绍了,这里只重点说说glDrawArrays这个函数。它有三个参数:
- mode:绘制模式。这里有很多中,比如我们用的
GLES20.GL_TRIANGLES
就是三角形;还有点GLES20.GL_POINTS
,绘制出来就是三个点;还有闭环的线GLES20.GL_LINE_LOOP
,绘制出来就是三角形的三条边;还有非闭环的线GLES20.GL_LINE_STRIP
等等 - first:从哪个点开始
- count:绘制点的数量
绘制结果就不上图了,就是一个三角形。
这样我们回顾了如何绘制三角形,接下来看看如果在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)
//准备坐标数据
GLES20.glVertexAttribPointer(objectPositionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer)
//绘制物体
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
//禁用顶点位置句柄
GLES20.glDisableVertexAttribArray(objectPositionParam)
}
可以看到在左右分别绘制了一个三角形,所以说在GvrView中左右其实是两个区域,而图形会在两个区域都进行绘制。同时我们也看到上面绘制的图形是无法变动的,没有随着设备的移动而变化,也就是说只是单纯的绘制在屏幕上,并没有绘制在VR空间中。下一步我们来看看如果让它动起来。
动起来
如果图形不动,那么就失去了VR的意义,而如何才能让图形动起来?这就用到了我们之前提到的着色器,我们知道在顶点着色器中可以对顶点坐标进行转换,正是通过它我们可以实现图形的各种移动或变形。
着色器
先来重新创建一个顶点着色器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:是全局的,在顶点和片段着色器中都可以访问,一般用来表示转换矩阵、颜色、材质等
- Attribute:只在顶点着色器中使用,一般用来表示顶点的一些数据,如坐标、顶点颜色等等;
- Varying:除它们俩还有一个Varying,它是用来在顶点着色器和片段着色器间传递数据的,一般在顶点着色器中修改它的值,在片段着色器中使用。
所以在我们的代码里u_MVP就是转换矩阵,通过它与vPosition相乘来得到新的坐标,这样我们通过修改u_MVP就可以实现渲染位置的变动。
片段着色器不变,保持原样即可。
初始化
那么接下来就是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就是这个转换矩阵,将顶点坐标与它相乘就会得到世界坐标系中的位置
视图坐标系:以观察点为中心的坐标系。我们知道观察的位置不同看到的景象也是不同的,所以需要将世界坐标转换成视图坐标,这个后面会处理
投影坐标系:以上都是针对三维的顶点坐标进行转换,但是最终呈现在屏幕上还是一个二维平面,所以需要一个投影过程,所以需要将视图坐标转换成投影坐标
屏幕坐标系:最后将投影坐标转成屏幕坐标并显示出来,这部分我们不需要自己处理。
所以可以看到物体的顶点通过以上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)
//准备坐标数据
GLES20.glVertexAttribPointer(objectPositionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer)
//绘制物体
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
//禁用顶点位置句柄
GLES20.glDisableVertexAttribArray(objectPositionParam)
}
注意这个函数有一个参数eye,这里面包含着当前视线的一些信息,比如当我们移动手机的时候,我们的视线是变化的,onDrawEye中的eye中的属性也是实时变化的。这样我们通过这种变化来调整绘制实现场景的移动。
首先通过相机和眼睛的位置,得到一个世界坐标系到眼坐标系(视图坐标系)的转换矩阵view;然后将物体的世界坐标转换成眼坐标modelView;在通过投影矩阵转换成投影坐标modelViewProjection;最后将这个最终的转换矩阵传入着色器的u_MVP,这样在着色器中顶点坐标通过这一系列转换就成功的计算成最终坐标,这样就可以进行绘制的。
计算完成后进行绘制即可。
可以看到,我们在VR世界中成功绘制了一个三角形,随着手机(视线)的移动景象也有了变化。