Android VR Player(全景视频播放器) [10]: VR全景视频渲染播放的实现(exoplayer,glsurfaceview,opengl es)

前言

此博客的大部分内容来自我的毕业设计论文,因此语言上会偏正式一点,如果您有任何问题或建议,欢迎留言。在此感谢实验室的聂师兄,全景视频render部分的代码设计主要参考了他所编写的代码来完成,他对视频渲染过程的讲解也让我对此部分有了更好的理解!

为了能播放MEPG-DASH标准的视频,我使用了ExoPlayer作为播放器,而非之前的MediaPlayer
,如有需要,请参考,ExoPlayer播放器MPEG-DASH视频播放

GLSurfaceView的使用介绍

针对VR视频的播放需求,由于需要使用OpenGL ES 来完成视频渲染工作,所以使用了GLSurfaceView。它是SurfaceView的实现,并使用特定的surface来展示OpenGL的渲染内容。它具有如下特点:

  1. 管理一个surface(一块可以组合进Android view系统的特殊内存);
  2. 管理EGL显示(即允许使用OpenGL进行渲染);
  3. 接受由用户提供的渲染器完成渲染工作;开启特定线程完成渲染;
  4. 支持按要求(on-demand rendering.)渲染和连续渲染(continuous rendering)两种模式。

这些特点都说明,GLSurfaceView很适合用来进行渲染工作。
GLSurfaceView的使用包含下面这几部分工作:

首先是初始化:即使用setRenderer(Renderer)来设置一个渲染器。具体的步骤为:

  1. 定制android.view.Surface:GLSurfaceView默认创建一个像素格式为PixelFormat.RGB_888的surface,根据具体的需求,可以选择需要的像素格式,如透明格式,则需要调用getHolder().setFormat(PixelFormat.TRANSLUCENT)来设置。
  2. 选择EGL配置:一个Android设备可能支持多种EGL配置,如通道(channels)数以及每个通道颜色位数不同则EGL配置也不同,所以,在渲染器之前必须指定EGL的配置。默认使用RGB通道和16位深度,在初始化GLSurfaceView时可以通过调用setEGLConfigChooser(EGLConfigChooser)方法来改变EGL配置。
  3. 调试选项(可选):可以通过调用setDebugFlags(int),和setGLWrapper(GLSurfaceView.GLWrapper)方法来指定GLSurfaceView的调试行为。
  4. 设置渲染器:初始化的最后一步是设置渲染器,通过调用setRenderer(GLSurfaceView.Renderer)来注册一个GLSurfaceView渲染器。真正的OpenGL渲染工作将由渲染器负责完成。
  5. 渲染模式:如在GLSurfaceView的特点中所介绍的,它支持按要求(on-demand rendering.)渲染和连续渲染(continuous rendering)两种模式,设定好渲染器,再使用setRenderMode(int)指定需要使用的渲染模式。

另外两个部分是Activity生命周期和事件处理。

Activity生命周期:GLSurfaceView会在Activity窗口暂停(pause)或恢复(resume)时会收到通知,并调用它的的onPause方法和 onResume方法。这是因为GLSurfaceView是一个重量级的控件,恢复和暂停渲染进程是为了使它能及时释放或重建OpenGL ES的资源。

事件处理:为了处理事件,和其他View的事件处理类似,即继承GLSurfaceView类并重载它的事件方法。事件处理过程中可能涉及到和渲染对象所在的渲染线程的通信,使用queueEvent(Runnable)可以简化这部分工作,当然也可以使用其他标准的进程通信机制中的方法。

VR视频的渲染

VR视频的渲染工作总结起来,主要是两大部分,一是球体的绘制,二是进行球体的纹理贴图工作。具体的实现细节较为复杂,如在完成贴图后,为了能让观众自由地切换视角,还需要使用投影和相机视图,并进行窗口裁剪等工作。VR全景视频完整的渲染播放流程如图1所示:
图1 VR视频渲染播放的流程

创建 GLSurfaceView 对象

在GLSurfaceView的使用介绍中提到真正的OpenGL渲染工作由渲染器来完成,而GLSurfaceView本身所做的工作并不多。根据Android官网的开发者指导,可以直接使用GLSurfaceView,但为了进行事件处理,本应用必须创建一个自己的MyGLSurfaceView,它继承自GLSurfaceView。

创建GLSurfaceView.Renderer类

GLSurfaceView.Renderer负责向GLSurfaceView的渲染工作,而渲染工作主要有下面这三个方法来完成:onSurfaceCreated():这个方法在GLSurfaceView被创建时,会调用一次,通常在这里进行一些初始化工作,如形状初始化,着色器的编译等;onDrawFrame():这个方法在每次绘制图像时被调用,绘制主要在这里完成; onSurfaceChanged():当几何图形发送改变时,会调用这个方法,如屏幕大小发生变化时。

本应用创建一个SphereVideoRenderer,它是GLSurfaceView.Renderer的实现。按照OpenGL ES 2.0可编程管线,绘制一个图形并最终展示在view(送入FrameBuffer中)的过程为:准备顶点(作为输入顶点着色器的顶点数据),顶点着色器处理,图元装配Primitive Assembly,光栅化(rasterization),Per-Fragment Operations(逐片段操作)。下面将对VR视频渲染的详细步骤进行说明。

数据的准备

第一步工作为准备绘制球形的顶点信息,即获取绘制一个球体所需的全部顶点的直角坐标。创建一个函数initSphereCoords(),它用来完成球体顶点坐标的计算,同时绘制球体的缓冲的准备也在此函数中进行。
图2 球坐标和直角坐标系
根据球坐标和直角坐标之间的关系(如图2所示),得到球坐标到直角坐标的公式

x = r ∗ s i n θ c o s ϕ x = r * sin\theta cos\phi x=rsinθcosϕ
y = r ∗ s i n θ s i n ϕ y = r * sin\theta sin\phi y=rsinθsinϕ
z = r ∗ c o s θ z = r * cos\theta z=rcosθ

由于在绘制时需要保证同一方向上的三角形被连续绘制,所以在OpenGL绘制方法中GLES20.glDrawArrays中指定绘制类型为GLES20.GL_TRIANGLE_STRIP,采用这种方法时,顶点缓冲中的顶点将按照V0V1V2,V1V2V3,V2V3V4…这样的方式连接成一个个三角形。
图3 球上顶点坐标计算示意
使用如下伪代码所示的方法在initSphereCoords()中计算球体顶点的坐标(计算过程示意如图3所示):

for(theta = 0;theta <= PAI;theta += thetaStep)
 for(phi = 0;phi <= 2*PAI;phi += phiStep)
  spherPoint[pointer++].x = r * sin(theta) * cos(phi);
  spherPoint[pointer++].y = r * sin(theta) * sin(phi);
  spherPoint[pointer++].z = r * cos(theta);
		
  spherPoint[pointer++].x = r * sin(theta+thetaStep) * cos(phi);
  spherPoint[pointer++].y = r * sin(theta+thetaStep) * sin(phi);
  spherPoint[pointer++].z = r * cos(theta+thetaStep);

其中r为绘制的球体的半径,设置为5,theta,phi为球体表面一个点的球坐标中的的theta,phi值。thetaStep和phiStep为根据实际需求调整选择的变化步长。在一个内循环中,计算的两个点的坐标,如图11所示的A,B,通过不断改变theta,phi的值,“遍历”整个球体表面,即可得到所要绘制的球体的顶点信息。然后为这些顶点开辟相应的缓冲区域(在OpenGL中称为准备顶点缓冲对象VBO(Vertex Buffer Object)和顶点数组对象VAO(Vertex Array Object))。

在计算绘制球所需要的顶点坐标信息的同时,需要计算纹理的坐标,使用如下伪代码所示的方法来计算纹理的坐标:

for(theta = 0;theta <= PAI;theta += thetaStep)
 for(phi = 0;phi <= 2*PAI;phi += phiStep)
  texturePoint[pointer++].x = phi / (2*PAI);
  texturePoint[pointer++].y = 1 - theta / PAI;
    
  texturePoint[pointer++].x = phi / (2*PAI);
  texturePoint[pointer++].y = 1 - (theta+thetaSetp) / PAI; 

由于纹理的(0,0)坐标在左下角,所以使用theta / PAI来表示纹理的y坐标就不正确,因为随着theta的递增,y在减小,所以,用1 - theta / PAI表示,而x坐标则可以使用phi / (2*PAI)来表示。
图4 贴图坐标计算示意

接着是准备顶点着色器和片元着色器。可编程管线给开发者提供了更多的自由,但同时开发者必须自己完成原本在固定管线中由系统自动完成的许多计算工作。具体来说,开发者必须提供如下的图形渲染管线细节:顶点着色器(vertex Shader):它用来绘制图形的形状;。顶点着色器采用着色器语言GLSL来编写(片元着色器相同),它是一种和C语言语法很类似的语言,一个基本的着色器程序包括变量声明,以及一个main函数。在顶点着色器中,完成的工作为指定球体顶点和纹理顶点。片元着色器(Fragment Shader) :它用来绘制图形的颜色或者是纹理。本应用片元着色器完成的工作为指定绘制球体的纹理,在着色器的main()函数中,使用“gl_FragColor = texture2D(sTexture, v_TexCoordinate);”来实现,sTexture为由外部传入的统一变量(使用uniform修饰符),v_TexCoordinate为纹理坐标,texture2D为着色器内建函数,这句话的作用为指定一个片元的颜色为v_TexCoordinate位置的sTexture纹理。

除了着色器外,还需要一个program,它是一个OpenGL ES的对象,其中包含了用来绘制一个或者多个形状的着色器。在定义好顶点着色器和片元着色器后,需要将其编译然后添加到program中,然后才能使用着色器。编译和添加的着色器的任务可以通过创建一个工具类方法来实现。分为如下几个步骤:创建program对象:GLES20.glCreateProgram();加载着色器 :SphereVideoRenderer.loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode),片元着色器采用同样的方法载入,不过将参数换成片元着色器的;添加着色器:GLES20.glAttachShader(mProgram, vertexShader),片元着色器采用同样的方法添加,不过将参数换成片元着色器的;链接到program:GLES20.glLinkProgram(mProgram),使mProgram成为可执行的program对象;使用program:GLES20.glUseProgram(mProgram),将mProgram添加到OpenGL环境中;

在onSurfaceCreated()中使用GLES20.glGetAttribLocation(mProgram, “attrib_name”);和GLES20.glGetUniformLocation(mProgram, “uniform_name”)两个方法可以分别获取着色器中attribute和uniform类型的变量的句柄(handle)。以纹理ID为参数创建一个SurfaceTexture,并使用ExoPlayer.setSurface(surface)把这个surface作为参数传递给mExoPlayer(在MySurfaceView中创建ExoPlayer的实例,它调用ExoPlayer.setDataSource(videourl)来设置视频数据来源)。

至此,数据的准备工作和OpenGL ES环境初始化基本完成,下一步为在onDrawFrame()中进行绘制工作。

开始绘制

使用OpengGL ES来绘制图形需要调用较多的函数,而且会用到很多相关参数,一个常用的做法是创建一个绘制方法,本应用中这个绘制方法为drawSphere(),然后在onDrawFrame()中去调用该方法。准备绘制方法drawSphere():在drawSphere(),并不是简单地直接使用 GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, vertexCount);来绘制球体,为了能使得用户观看到的渲染效果接近真实世界的观察效果,需要进行一些矩阵的计算。
计算机图形学中的观察是建立在虚拟相机的基础上(《计算机图形学》 图5,6,7引用自此书),图5则是对虚拟相机模型的抽象,其中投影线相交于投影中心(COP:Center of Projection),而COP就对应于人眼或者是相机镜头。

图5 虚拟相机模型

为了使得观察过程更加灵活,通常的做法是把对虚拟相机的控制分解成设置相机的位置和方向和应用投影矩阵两个基本的操作。其中,设置照相机的位置和方向由模-视变换来完成,顶点经过该变换之后会位于相机坐标系中。然后再将指定的投影矩阵应用于顶点,进行投影变换。并将视见体内部对象变换到指定的裁剪立方体的内部。变换的流程如图6所示:

图6 虚拟相机模型中的坐标变换

在实际开发中,矩阵计算往往直接使用android.opengl.Matrix提供的方法即可,而不用开发者自己完成复杂的矩阵计算工作。
使用Matrix.setLookAtM(mViewMatrix, 0, eyeX, eyeY, eyeZ, lookX, lookY, lookZ, upX, upY, upZ)方法可以设置相机在空间中的位置,其中eye参数为相机坐标,look参数为观察的目标的坐标,up参数为相机正上方向量。 在本应用中,各个参数设置为Matrix.setLookAtM(mViewMatrix, 0, 0, 0, 0, 0, 0, -1, 0, 1, 0)。通常情况,将相机的初始方向指向z轴负方向,这是因为这样才能看见位于相机前方的观察对象;仅指定相机的空间位置还不能将相机唯一确定下来下来,因为此时相机还可以进行旋转,通过指定相机正上方向量来将相机确定下来。

下一步是投影矩阵的设置。它的作用是设置一个视见体(如图7所示),用以裁剪形状(即在视见体内部的形状才能被投影到投影平面上,其余部分则被裁剪掉)。棱台视见体是定义视见体的常用方法,它由左右裁剪平面(left和right),上下裁剪平面(top和bottom),远近裁剪平面(near和far)来决定。

图7 棱台视见体

使用Matrix.frustumM(mProjectionMatrix, 0, left, right, bottom, top, near, far)方法来设置投影矩阵ProjectionMatrix。最后两个参数near,far,根据实际的渲染测试效果,可固定为1.2和5.0,而前面的 left, right, bottom, top几个参数则可能需要在onSurfaceChanged中进行修改,以确保屏幕改变时,GLSurfaceView的显示效果仍然是开发者所需要的。

然后需要一个供用户转换视角的旋转矩阵,同样使用android.opengl.Matrix提供的方法来完成变换矩阵的计算。 如Matrix.setRotateM(mRotationMatrix, 0, angle, 0.0f, -1.0f, 0),为了能通过屏幕触摸,陀螺仪变化等外部事件来改变视角(即进行旋转),可以暴露一个设置旋转角度的方法出来,供其他类调用。

最后通过之前拿到的着色器变量的句柄,将矩阵变换应用于顶点,并调用 GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, vertexCount)方法进行绘制。在onDrawFrame()方法中调用drawSphere()进行绘制:开始绘制之前,调用GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT |GLES20.GL_DEPTH_BUFFER_BIT)方法来清除屏幕。为了用户能使用VR眼镜等工具获得立体观影效果,需要作分屏(设置视口)处理,即将屏幕分成左右两个小屏幕,在两个小屏幕中分别进行绘制。使用 GLES20.glViewport(GLint x,GLint y,GLsizei width,GLsizei height)方法来进行分屏,然后调用drawSphere()进行绘制。

事件处理

在GLSurfaceView的使用介绍的事件处理部分中提到,使用queueEvent(Runnable)可以简化事件处理所在线程与渲染线程之间的通信工作[10]。为了保证应用的正常运行,必须在GLSurfaceView对的窗口暂停(pause)或恢复(resume)这两个事件进行处理。其中onResume()在窗口恢复时调用。而onPause()在窗口暂停时调用:在此回调方法中调用父类的onPause()方法和ExoPlayer.release()释放相关资源。

视频播放控制

为了顺利进行视频的播放和控制,并且保证应用程序的健壮性,需要对ExoPlayer的线程模型(threading model)有较好的了解,确保在合适的时间点调用相应的方法,并在完成播放后及时释放相关资源。下面主要对视频播放/暂停,进度条拖动播放以及根据陀螺仪改变视角进行说明。

视频播放/暂停,进度条拖动播放

安卓提供了一个视频的播放控制MediaController组件,但是它不太适合本系统的要求,所以需要重新定义一个播放控制类MyMediaController,它继承自FrameLayout,使用时作为一个自定义组件来使用,主要完成视频播放/暂停,进度条拖动播放的控制。

播放暂停功能比较简单,在MediaController的布局中添加一个播放暂停的按钮,然后在播放Activity添加按钮的点击事件,通过调用ExoPlayer.setPlayWhenReady(true)和ExoPlayer.setPlayWhenReady(false)来实现播放和暂停功能。

进度条拖动播放的控制是通过对SeekBar的拖动事件的监听结合ExoPlayer.seekTo()方法来实现。在SeekBar的OnSeekBarChangeListener的onProgressChanged回调中,可以获取到SeekBar进度的改变;当onStopTrackingTouch(SeekBar bar)方法被调用时,说明拖动停止,此时视频应该跳转到指定的进度。ExoPlayer.seekTo()接受的参数为毫秒为单位的视频时间点,因此调用Progress.setMax(1000)方法,将进度条最大值设置为1000,视频应该跳转到的进度则可以表示为(Duration * bar.getProgress()) / 1000。

MyMediaController的显示和隐藏。在playerLayout(视频播放Activity的布局)中调用addView即可将自定义组件MyMediaController的view添加到视频播放view上,而要实现MyMediaController的自动隐藏(即在几秒钟无操作的情况下,播放控制组件会自动消失)和触摸呼出,需要使用到Handler和控件的setVisibility()方法。

创建一个内部类Handler来处理与MyMediaController之间的异步线程消息。MyMediaController显示时,调用Handler.sendMessageDelayed来发送一条延迟消息(这里可以指定延迟时间,即MyMediaController的显示时间),重写Handler的handleMessage方法来处理消息,在一定延迟后,Handler收到了消息,如果当前状态不是拖动进度条并且MyMediaController是显示着的话,就发送隐藏MyMediaController的消息给MyMediaController,它将调用setVisibility(View.GONE)方法来进行隐藏;而触摸呼出MyMediaController的方法则是通过对屏幕触摸事件的监听来实现的,当检测到触摸事件时,调用setVisibility(View.VISIBLE)即可显示MyMediaController。

根据陀螺仪改变视角

本部分的工作为获取陀螺仪的数据,用来调整播放视角。在 开始绘制 中的矩阵变换部分提到 SphereVideoRender提供了设置旋转角度的方法出来,供其他类调用。所以,只需要获取陀螺仪的数据并将其转换为相应的旋转角度,传入SphereVideoRender提供的方法即可实现视角变换的功能。

PlayerActivity中注册传感器事件监听器SensorEventListener,由于本应用只需要陀螺仪的数据,所以,使用一个条件判断对事件进行过滤:
if(event.sensor.getType() == Sensor.TYPE_GYROSCOPE)

对于陀螺仪,从事件监听返回的结果是x、y、z三个轴方向上的角速度(弧度/秒),可以分别从values[0]、values[1]、values[2]获取到。取得上述的角速度的数据,计算两次改变之间的时间差,由角速度乘以时间的公式即可计算出各个轴上改变的角度。然后调用Math.toDegrees()方法将弧度至转换成角度制。

  • 3
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值