如何在Android上渲染VR场景——GvrView

前言

前几章我们简单介绍了一下如何通过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中清除缓冲区(用刚才设置的颜色),这样就绘制了蓝色的背景色,运行结果如下:

image.png

这样我们就可以看到背景色了,下一步我们就可以绘制一些物体。

OpenGL绘制三角形

我们先来复习一下如何用OpenGL来绘制图形。OpenGL绘制的基本单元有点、线和三角形,其他所有的图形实际上都是由三角形组成的。所以我们来看看如何用OpenGL绘制三角形。

由于OpenGL相关知识过于庞大,这里就不详细解释太多,大家自行查阅OpenGL资料。

着色器

首先我们需要创建顶点着色器(Vrtex Shader)和片段着色器(Fragment Shader)。着色器是用来实现图像渲染的,用来替代固定渲染管线的可编辑程序,在整个绘制过程中发挥着重要的作用,如图:

image.png

其中顶点着色器主要就是处理顶点数据的,比如位置、颜色;片段着色器就是处理每个片段的颜色和属性。着色器相关知识一两句说不清楚,大家自行查阅资料吧。

其实着色器就是一段程序代码,所以我们可以直接用字符串。但是在Android Studio中提供了着色器类型文件,即glsl文件。如果想创建这样的文件,首先需要为Android Studio安装一个GLSL Support插件

image.png

然后我们在创建新文件的菜单中就会发现多出一个GLSL Shader类型

image.png

我们在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)
}

在这里主要做以下操作:

  • 初始化缓冲
  • 加载着色器
  • 获取着色器属性:这里可以看到通过glGetUniformLocationglGetAttribLocation分别获取两种不同类型的属性。
  • 初始化物体的世界坐标转换矩阵modelTarget

这里来说一下modelTarget,要理解这部分以及下面计算绘制的部分,需要你先了解OpenGL中各种坐标系的知识,因为这部分知识也很庞大,我这里简单说一下,大家想详细了解可以自行查询相关资料。

坐标系

在OpenGL中存在几种坐标系:

  • 局部坐标系:物体的局部空间,比如上面我们定义的顶点坐标triangleCoords,它是物体的本地坐标

  • 世界坐标系:物体在三维空间中的坐标。我们需要将物体的各个顶点坐标转换到三维空间的坐标,上面的modelTarget就是这个转换矩阵,将顶点坐标与它相乘就会得到世界坐标系中的位置

  • 视图坐标系:以观察点为中心的坐标系。我们知道观察的位置不同看到的景象也是不同的,所以需要将世界坐标转换成视图坐标,这个后面会处理

  • 投影坐标系:以上都是针对三维的顶点坐标进行转换,但是最终呈现在屏幕上还是一个二维平面,所以需要一个投影过程,所以需要将视图坐标转换成投影坐标

  • 屏幕坐标系:最后将投影坐标转成屏幕坐标并显示出来,这部分我们不需要自己处理。

所以可以看到物体的顶点通过以上4个坐标系(局部坐标系、世界坐标系、视图坐标系和投影坐标系)和三个变换矩阵,得到了最终坐标才进行绘制,这部分处理如图:


image.png

计算绘制

简单复习了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世界中成功绘制了一个三角形,随着手机(视线)的移动景象也有了变化。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容