【Android 音视频开发打怪升级:OpenGL渲染视频画面篇】一、初步了解OpenGL ES

12,334 阅读20分钟

【声 明】

首先,这一系列文章均基于自己的理解和实践,可能有不对的地方,欢迎大家指正。
其次,这是一个入门系列,涉及的知识也仅限于够用,深入的知识网上也有许许多多的博文供大家学习了。
最后,写文章过程中,会借鉴参考其他人分享的文章,会在文章最后列出,感谢这些作者的分享。

码字不易,转载请注明出处!

教程代码:【Github传送门

目录

一、Android音视频硬解码篇:
二、使用OpenGL渲染视频画面篇
三、Android FFmpeg音视频解码篇

本文你可以了解到

本文主要介绍OpenGL相关的基础知识,包括坐标系、着色器、基本渲染流程等。

一 简介

提到OpenGL,想必很多人都会说,我知道这个东西,可以用来渲染2D画面和3D模型,同时又会说,OpenGL很难、很高级,不知道怎么用。

1、为什么OpenGL“感觉很难”?

  • 函数多且杂,渲染流程复杂
  • GLSL着色器语言不好理解
  • 面向过程的编程思维,和Java等面向对象的编程思维不同

2、OpenGL ES是什么?

为了解决以上问题,让OpenGL“学起来不是很难”,需要把其分解成一些简单的步骤,然后简单的东西串联起来,一切就水到渠成了。

首先,来看看什么是OpenGL。

  • CPU和GPU

在手机上,有两大元件,一个是CPU,一个是GPU。而手机上显示图形界面也有两种方式,一个是使用CPU来渲染,一个是使用GPU来渲染,可以说,GPU渲染其实是一种硬件加速。

为什么GPU可以大大提高渲染速度,因为GPU最擅长的是并行浮点运算,可以用来对许许多多的像素做并行运算。

OpenGL(Open Graphics Library)则是间接操作GPU的工具,是一组定义好的跨平台和跨语言的图形API,是可用于2D和3D画面渲染的底层图形库,是由各个硬件厂家具体实现的编程接口。

  • OpenGL 与 OpenGL ES

OpenGL ES 全称:OpenGL for Embedded Systems,是OpenGL 的子集,是针对手机 PAD等小型设备设计的,删减了不必须的方法、数据类型、功能,减少了体积,优化了效率。

3、 OpenGL ES版本

目前主要版本有1.0/1.1/2.0/3.0/3.1

  • 1.0:Android 1.0和更高的版本支持这个API规范
  • 2.0:不兼容 OpenGL ES 1.x。Android 2.2(API 8)和更高的版本支持这个API规范
  • 3.0:向下兼容 OpenGL ES 2.x。Android 4.3(API 18)及更高的版本支持这个API规范
  • 3.1:向下兼容 OpenGL ES3.0/2.0。Android 5.0(API 21)和更高的版本支持这个API规范

2.0 版本是 Android 目前支持最广泛的版本,后续主要以该版本为主,进行介绍和代码编写。

二、OpenGL ES坐标系

在音视频开发中,涉及到的坐标系主要有两个:世界坐标和纹理坐标。

由于基本不涉及3D贴图,所以只看x/y轴坐标,忽略z轴坐标,涉及到3D相关知识可自行Google,不在讨论范围内。

首先来看两个图:

世界坐标

纹理坐标

  • OpenGL ES世界坐标

通过名字就可以知道,这是OpenGL自己世界的坐标,是一个标准化坐标系,范围是 -1 ~ 1,原点在中间。

  • OpenGL ES纹理坐标

纹理坐标,其实就是屏幕坐标,标准的纹理坐标原点是在屏幕的左下方,而Android系统坐标系的原点是在左上方的。这是Android使用OpenGL需要注意的一个地方。

纹理坐标的范围是 0 ~ 1。

注:坐标系的xy轴方向很重要,决定了如何做顶点坐标和纹理坐标映射。

那么,这两个坐标系究竟有什么关系呢?

世界坐标,是用于显示的坐标,即像素点应该显示在哪个位置由世界坐标决定。

纹理坐标,表示世界坐标指定的位置点想要显示的颜色,应该在纹理上的哪个位置获取。即颜色所在的位置由纹理坐标决定。

两者之间需要做正确的映射,才能正常的显示一张画面。

三、OpenGL 着色器语言 GLSL

在OpenGL 2.0以后,加入了新的可编程渲染管线,可以更加灵活的控制渲染。但也因此需要学习多一门针对GPU的编程语言,语法与C语言类似,名为GLSL。

  • 顶点着色器 & 片元着色器

在介绍GLSL之前,先来看两个比较陌生的名词:顶点着色器和片元着色器。

着色器,是一种可运行在GPU上的小程序,用GLSL语言编写。从命名上,顶点着色器是用于操控顶点的程序,而片元着色器是用于操控像素颜色属性的程序。

简单理解:其实就是对应了以上两个坐标系:顶点着色器对应世界坐标,片元着色器对应纹理坐标。

画面上的每个点,都会执行一次顶点和片元着色器中的程序片段,并且是并行执行,最后渲染到屏幕上。

  • GLSL编程

下面,通过一个最简单的顶点着色器和片元着色器来简单介绍一下GLSL语言

#顶点着色器

attribute vec4 aPosition;

void main() {
  gl_Position = aPosition;
}
#片元着色器

void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0)
}

首先可以看到,GLSL语言是一种类C语言,着色器的框架基本和C语言一样,在最上面声明变量,接着是main函数。在着色器中,有几个内建的变量,可以直接使用(这里只列出音视频开发常用的,还有其他的一些3D开发会用到的):

  • 顶点着色器的内建输入变量

gl_Position:顶点坐标
gl_PointSize:点的大小,没有赋值则为默认值1

  • 片元着色器内建输出变量

gl_FragColor:当前片元颜色

看回上面的着色器代码。

1)在顶点着色器中,传入了一个vec4的顶点坐标xyzw,然后直接传递给内建变量gl_Position,即直接根据顶点坐标渲染,不再做位置变换。

注:顶点坐标是在Java代码中传入的,后面会讲到,另外w是齐次坐标,2D渲染没有作用

2)在片元着色器中,直接给gl_FragColor赋值,依然是一个vec4类型的数据,这里表示rgba颜色值,为红色。

可以看到vec4是一个4维向量,可用于表示坐标xyzw,也可用表示rgba,当然还有vec3,vec2等,可以参考这篇文章:着色器语言GLSL,讲的非常详细,建议看看。

这样,两个简单的着色器串联起来后,每一个顶点(像素)都会显示一个红点,最后屏幕会显示一个红色的画面。

具体GLSL关于数据类型和语法不再展开介绍,后面涉及到的GLSL代码会做更深入的讲解。更详细的可以参考这位作者的文章【着色器语言GLSL】,非常详尽。

四、Android OpenGL ES渲染流程

OpenGL的渲染流程说实话是比较繁琐的,也是让很多人望而生畏的地方,但是,如果归结起来,其实整个渲染流程基本是固定的,只要把它按照固定的流程封装好,其实并没有那么复杂。

接下来,就进入实战,一层一层扒开OpengGL的神秘面纱。

1、初始化

在Android中,OpenGL通常配合GLSurfaceView使用,在GLSurfraceView中,Google已经封装好了渲染的基础流程。

这里需要单独强调一下,OpenGL是基于线程的一个状态机,有关OpenGL的操作,比如创建纹理ID,初始化,渲染等,都必须要在同一个线程中完成,否则会造成异常。

通常开发者在刚刚接触OpenGL的时候并不能深刻体会到这种机制,原因是Google在GLSurfaceView中已经帮开发者做了这部分的内容。这是OpenGL非常重要的一个方面,在后续的有关EGL的文章中会继续深入了解到。

  1. 新建页面
class SimpleRenderActivity : AppCompatActivity() {
    //自定义的OpenGL渲染器,详情请继续往下看
    lateinit var drawer: IDrawer

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_simpler_render)

        drawer = if (intent.getIntExtra("type", 0) == 0) {
            TriangleDrawer()
        } else {
            BitmapDrawer(BitmapFactory.decodeResource(CONTEXT!!.resources, R.drawable.cover))
        }
        initRender(drawer)
    }

    private fun initRender(drawer: IDrawer) {
        gl_surface.setEGLContextClientVersion(2)
        gl_surface.setRenderer(SimpleRender(drawer))
    }

    override fun onDestroy() {
        drawer.release()
        super.onDestroy()
    }
}
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <android.opengl.GLSurfaceView
            android:id="@+id/gl_surface"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
</android.support.constraint.ConstraintLayout>

页面非常简单,放置了一个满屏的GLSurfaceView,初始化的时候,设置了OpenGL使用的版本为2.0,然后配置了渲染器SimpleRender,继承自GLSurfaceView.Renderer

IDrawer将在绘制三角形的时候具体讲解,定义该接口类只是为了方便拓展,也可以直接将渲染代码写在SimpleRender中。

  1. 实现渲染接口
class SimpleRender(private val mDrawer: IDrawer): GLSurfaceView.Renderer {

    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        GLES20.glClearColor(0f, 0f, 0f, 0f)
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
        mDrawer.setTextureID(OpenGLTools.createTextureIds(1)[0])
    }

    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
    }

    override fun onDrawFrame(gl: GL10?) {
        mDrawer.draw()
    }
}

注意到,实现了三个回调接口,这三个接口就是Google封装好的流程中,暴露出来的接口,留给给开发者实现初始化和渲染,并且这三个接口的回调都在同一个线程中。

  • 在onSurfaceCreated中,调用了两句OpenGL ES的代码实现清屏,清屏颜色为黑色。
GLES20.glClearColor(0f, 0f, 0f, 0f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

同时,创建了一个纹理ID,并设置给Drawer,如下:

fun createTextureIds(count: Int): IntArray {
    val texture = IntArray(count)
    GLES20.glGenTextures(count, texture, 0) //生成纹理
    return texture
}
  • 在onSurfaceChanged中,调用glViewport,设置了OpenGL绘制的区域宽高和位置

这里所说的绘制区域,是指OpenGL在GLSurfaceView中的绘制区域,一般都是全部铺满。

GLES20.glViewport(0, 0, width, height)
  • 在onDrawFrame中,就是真正实现绘制的地方了。该接口会不停的回调,刷新绘制区域。这里使用一个简单的三角形绘制来说明整个绘制流程。
2、渲染一个简单的三角形

先定义一个渲染接口类:

interface IDrawer {
    fun draw()
    fun setTextureID(id: Int)
    fun release()
}
class TriangleDrawer(private val mTextureId: Int = -1): IDrawer {
    //顶点坐标
    private val mVertexCoors = floatArrayOf(
        -1f, -1f,
         1f, -1f,
         0f,  1f
    )

    //纹理坐标
    private val mTextureCoors = floatArrayOf(
        0f,   1f,
        1f,   1f,
        0.5f, 0f
    )

    //纹理ID
    private var mTextureId: Int = -1

    //OpenGL程序ID
    private var mProgram: Int = -1

    // 顶点坐标接收者
    private var mVertexPosHandler: Int = -1
    // 纹理坐标接收者
    private var mTexturePosHandler: Int = -1

    private lateinit var mVertexBuffer: FloatBuffer
    private lateinit var mTextureBuffer: FloatBuffer

    init {
        //【步骤1: 初始化顶点坐标】
        initPos()
    }

    private fun initPos() {
        val bb = ByteBuffer.allocateDirect(mVertexCoors.size * 4)
        bb.order(ByteOrder.nativeOrder())
        //将坐标数据转换为FloatBuffer,用以传入给OpenGL ES程序
        mVertexBuffer = bb.asFloatBuffer()
        mVertexBuffer.put(mVertexCoors)
        mVertexBuffer.position(0)

        val cc = ByteBuffer.allocateDirect(mTextureCoors.size * 4)
        cc.order(ByteOrder.nativeOrder())
        mTextureBuffer = cc.asFloatBuffer()
        mTextureBuffer.put(mTextureCoors)
        mTextureBuffer.position(0)
    }

    override fun setTextureID(id: Int) {
        mTextureId = id
    }
    
    override fun draw() {
        if (mTextureId != -1) {
            //【步骤2: 创建、编译并启动OpenGL着色器】
            createGLPrg()
            //【步骤3: 开始渲染绘制】
            doDraw()
        }
    }

    private fun createGLPrg() {
        if (mProgram == -1) {
            val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, getVertexShader())
            val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, getFragmentShader())

            //创建OpenGL ES程序,注意:需要在OpenGL渲染线程中创建,否则无法渲染
            mProgram = GLES20.glCreateProgram()
            //将顶点着色器加入到程序
            GLES20.glAttachShader(mProgram, vertexShader)
            //将片元着色器加入到程序中
            GLES20.glAttachShader(mProgram, fragmentShader)
            //连接到着色器程序
            GLES20.glLinkProgram(mProgram)

            mVertexPosHandler = GLES20.glGetAttribLocation(mProgram, "aPosition")
            mTexturePosHandler = GLES20.glGetAttribLocation(mProgram, "aCoordinate")
        }
        //使用OpenGL程序
        GLES20.glUseProgram(mProgram)
    }

    private fun doDraw() {
        //启用顶点的句柄
        GLES20.glEnableVertexAttribArray(mVertexPosHandler)
        GLES20.glEnableVertexAttribArray(mTexturePosHandler)
        //设置着色器参数
        GLES20.glVertexAttribPointer(mVertexPosHandler, 2, GLES20.GL_FLOAT, false, 0, mVertexBuffer)
        GLES20.glVertexAttribPointer(mTexturePosHandler, 2, GLES20.GL_FLOAT, false, 0, mTextureBuffer)
        //开始绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
    }

    override fun release() {
        GLES20.glDisableVertexAttribArray(mVertexPosHandler)
        GLES20.glDisableVertexAttribArray(mTexturePosHandler)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
        GLES20.glDeleteTextures(1, intArrayOf(mTextureId), 0)
        GLES20.glDeleteProgram(mProgram)
    }

    private fun getVertexShader(): String {
        return "attribute vec4 aPosition;" +
                "void main() {" +
                "  gl_Position = aPosition;" +
                "}"
    }

    private fun getFragmentShader(): String {
        return "precision mediump float;" +
                "void main() {" +
                "  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);" +
                "}"
    }

    private fun loadShader(type: Int, shaderCode: String): Int {
        //根据type创建顶点着色器或者片元着色器
        val shader = GLES20.glCreateShader(type)
        //将资源加入到着色器中,并编译
        GLES20.glShaderSource(shader, shaderCode)
        GLES20.glCompileShader(shader)

        return shader
    }
}

虽然只是画一个简单的三角形,代码依然看起来很复杂。这里把它拆解为三个步骤,就比较清晰明了了。

1) 初始化顶点坐标

前面我们讲到OpenGL的世界坐标和纹理坐标,在绘制前就需要先把这两个坐标确定好。

【重要提示】

有一点还没说的是,OpenGL ES所有的画面都是由三角形构成的,比如一个四边形由两个三角形构成,其他更复杂的图形也都可以分割为大大小小的三角形。

因此,顶点坐标也是根据三角形的连接来设置的。其绘制方式有三种:

  • GL_TRIANGLES:独立顶点的构成三角形

GL_TRIANGLES

  • GL_TRIANGLE_STRIP:复用顶点构成三角形

GL_TRIANGLE_STRIP

  • GL_TRIANGLE_FAN:复用第一个顶点构成三角形

GL_TRIANGLE_FAN

通常情况下,一般使用GL_TRIANGLE_STRIP绘制模式。那么一个四边形的顶点顺序看起来是这样子的(v1-v2-v3)(v2-v3-v4)

顶点坐标顺序

对应的纹理坐标也要和顶点坐标顺序一致,否则会出现颠倒,变形等异常

纹理坐标顺序

由于绘制的是三角形,所以两个坐标如下(这里只设置xy轴坐标,忽略z轴坐标,每两个数据构成一个坐标点):

//顶点坐标
private val mVertexCoors = floatArrayOf(
    -1f, -1f,
     1f, -1f,
     0f,  1f
)
//纹理坐标
private val mTextureCoors = floatArrayOf(
    0f,   1f,
    1f,   1f,
    0.5f, 0f
)

在initPos方法中,由于底层不能直接接收数组,所以将数组转换为ByteBuffer

2) 创建、编译并启动OpenGL着色器

 private fun createGLPrg() {
    if (mProgram == -1) {
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, getVertexShader())
        val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, getFragmentShader())

        //创建OpenGL ES程序,注意:需要在OpenGL渲染线程中创建,否则无法渲染
        mProgram = GLES20.glCreateProgram()
        //将顶点着色器加入到程序
        GLES20.glAttachShader(mProgram, vertexShader)
        //将片元着色器加入到程序中
        GLES20.glAttachShader(mProgram, fragmentShader)
        //连接到着色器程序
        GLES20.glLinkProgram(mProgram)

        mVertexPosHandler = GLES20.glGetAttribLocation(mProgram, "aPosition")
        mTexturePosHandler = GLES20.glGetAttribLocation(mProgram, "aCoordinate")
    }
    //使用OpenGL程序
    GLES20.glUseProgram(mProgram)
}

private fun getVertexShader(): String {
    return "attribute vec4 aPosition;" +
            "void main() {" +
            "  gl_Position = aPosition;" +
            "}"
}

private fun getFragmentShader(): String {
    return "precision mediump float;" +
            "void main() {" +
            "  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);" +
            "}"
}

private fun loadShader(type: Int, shaderCode: String): Int {
    //根据type创建顶点着色器或者片元着色器
    val shader = GLES20.glCreateShader(type)
    //将资源加入到着色器中,并编译
    GLES20.glShaderSource(shader, shaderCode)
    GLES20.glCompileShader(shader)

    return shader
}

上面已经说过,GLSL是针对GPU的编程语言,而着色器就是一段小程序,为了能够运行这段小程序,需要先对其进行编译和绑定,才能使用。

本例中的着色器就是上文提到的最简单的着色器。

可以看到,着色器其实就是一段字符串

进入loadShader中,通过GLES20.glCreateShader,根据不同类型,获取顶点着色器和片元着色器。

然后调用以下方法,编译着色器

GLES20.glShaderSource(shader, shaderCode)
GLES20.glCompileShader(shader)

编译好着色器以后,就是绑定,连接,启用程序即可。

还记得上面说过,着色器中的坐标是由Java传递给GLSL吗?

细心的你可能发现了这两句代码

mVertexPosHandler = GLES20.glGetAttribLocation(mProgram, "aPosition")
mTexturePosHandler = GLES20.glGetAttribLocation(mProgram, "aCoordinate")

没错,这就是Java和GLSL交互的通道,通过属性可以给GLSL设置相关的值。

3) 开始渲染绘制

private fun doDraw() {
    //启用顶点的句柄
    GLES20.glEnableVertexAttribArray(mVertexPosHandler)
    GLES20.glEnableVertexAttribArray(mTexturePosHandler)
    //设置着色器参数, 第二个参数表示一个顶点包含的数据数量,这里为xy,所以为2
    GLES20.glVertexAttribPointer(mVertexPosHandler, 2, GLES20.GL_FLOAT, false, 0, mVertexBuffer)
    GLES20.glVertexAttribPointer(mTexturePosHandler, 2, GLES20.GL_FLOAT, false, 0, mTextureBuffer)
    //开始绘制
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 3)
}

首先激活着色器的顶点坐标和纹理坐标属性,然后把初始化好的坐标传递给着色器,最后启动绘制:

GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 3)

绘制有两种方式:glDrawArrays和glDrawElements,两者区别在于glDrawArrays是直接使用定义好的顶点顺序进行绘制;而glDrawElements则是需要定义另外的索引数组,来确认顶点的组合和绘制顺序。

通过以上步骤,就可以在屏幕上看到一个红色的三角形了。

三角形

可能有人就有疑问了:绘制三角形的时候只是直接设置了像素点的颜色值,并没有用到纹理,纹理到底有什么用呢?

接下来,就用纹理来显示一张图片,看看纹理到底怎么使用。

建议先看清楚绘制三角形的流程,绘制图片就是基于以上流程,重复代码就不再贴出。

3、纹理贴图,显示一张图片

以下只贴出和绘制三角形不一样的部分代码,详细代码请看源码

class BitmapDrawer(private val mTextureId: Int, private val mBitmap: Bitmap): IDrawer {
    //-------【注1:坐标变更了,由四个点组成一个四边形】-------
    // 顶点坐标
    private val mVertexCoors = floatArrayOf(
        -1f, -1f,
        1f, -1f,
        -1f, 1f,
        1f, 1f
    )

    // 纹理坐标
    private val mTextureCoors = floatArrayOf(
        0f, 1f,
        1f, 1f,
        0f, 0f,
        1f, 0f
    )
    
    //-------【注2:新增纹理接收者】-------
    // 纹理接收者
    private var mTextureHandler: Int = -1

    fun draw() {
        if (mTextureId != -1) {
            //【步骤2: 创建、编译并启动OpenGL着色器】
            createGLPrg()
            //-------【注4:新增两个步骤】-------
            //【步骤3: 激活并绑定纹理单元】
            activateTexture()
            //【步骤4: 绑定图片到纹理单元】
            bindBitmapToTexture()
            //----------------------------------
            //【步骤5: 开始渲染绘制】
            doDraw()
        }
    }
    
    private fun createGLPrg() {
        if (mProgram == -1) {
            //省略与绘制三角形一致的部分
            //......
        
            mVertexPosHandler = GLES20.glGetAttribLocation(mProgram, "aPosition")
            mTexturePosHandler = GLES20.glGetAttribLocation(mProgram, "aCoordinate")
            //【注3:新增获取纹理接收者】
            mTextureHandler = GLES20.glGetUniformLocation(mProgram, "uTexture")
        }
        //使用OpenGL程序
        GLES20.glUseProgram(mProgram)
    }

    private fun activateTexture() {
        //激活指定纹理单元
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        //绑定纹理ID到纹理单元
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId)
        //将激活的纹理单元传递到着色器里面
        GLES20.glUniform1i(mTextureHandler, 0)
        //配置边缘过渡参数
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
    }

    private fun bindBitmapToTexture() {
        if (!mBitmap.isRecycled) {
            //绑定图片到被激活的纹理单元
            GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, mBitmap, 0)
        }
    }

    private fun doDraw() {
        //省略与绘制三角形一致的部分
        //......
        
        //【注5:绘制顶点加1,变为4】
        //开始绘制:最后一个参数,将顶点数量改为4
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
    }

    private fun getVertexShader(): String {
        return "attribute vec4 aPosition;" +
                "attribute vec2 aCoordinate;" +
                "varying vec2 vCoordinate;" +
                "void main() {" +
                "  gl_Position = aPosition;" +
                "  vCoordinate = aCoordinate;" +
                "}"
    }

    private fun getFragmentShader(): String {
        return "precision mediump float;" +
                "uniform sampler2D uTexture;" +
                "varying vec2 vCoordinate;" +
                "void main() {" +
                "  vec4 color = texture2D(uTexture, vCoordinate);" +
                "  gl_FragColor = color;" +
                "}"
    }
    
    //省略和绘制三角形内容一致的部分
    //......
}

不一致的地方,代码中已经做了标识(见代码中的【注:x】)。逐个来看看:

1)顶点坐标

顶点坐标和纹理坐标由3个变成4个,组成一个长方形,组合方式也是GL_TRIANGLE_STRIP。

2)着色器

首先介绍一下GLSL中的限定符

  • attritude:一般用于各个顶点各不相同的量。如顶点颜色、坐标等。
  • uniform:一般用于对于3D物体中所有顶点都相同的量。比如光源位置,统一变换矩阵等。
  • varying:表示易变量,一般用于顶点着色器传递到片元着色器的量。 const:常量。

各行代码解析如下:

private fun getVertexShader(): String {
    return  //顶点坐标
            "attribute vec2 aPosition;" +
            //纹理坐标
            "attribute vec2 aCoordinate;" +
            //用于传递纹理坐标给片元着色器,命名和片元着色器中的一致
            "varying vec2 vCoordinate;" +
            "void main() {" +
            "  gl_Position = aPosition;" +
            "  vCoordinate = aCoordinate;" +
            "}"
}

private fun getFragmentShader(): String {
    return  //配置float精度,使用了float数据一定要配置:lowp(低)/mediump(中)/highp(高)
            "precision mediump float;" +
            //从Java传递进入来的纹理单元
            "uniform sampler2D uTexture;" +
            //从顶点着色器传递进来的纹理坐标
            "varying vec2 vCoordinate;" +
            "void main() {" +
            //根据纹理坐标,从纹理单元中取色
            "  vec4 color = texture2D(uTexture, vCoordinate);" +
            "  gl_FragColor = color;" +
            "}"
}

绘制过程新增了两个步骤:

3)激活并绑定纹理单元

private fun activateTexture() {
    //激活指定纹理单元
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
    //绑定纹理ID到纹理单元
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId)
    //将激活的纹理单元传递到着色器里面
    GLES20.glUniform1i(mTextureHandler, 0)
    //配置纹理过滤模式
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat())
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
    //配置纹理环绕方式
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
}

由于显示图片需要用到纹理单元来传递整张图片的内容,所以首先需要激活一个纹理单元。

为什么说是一个纹理单元?
因为OpenGL ES中内置了很多个纹理单元,并且是连续,比如GLES20.GL_TEXTURE0,GLES20.GL_TEXTURE1,GLES20.GL_TEXTURE3...可以选择其中一个,一般默认选第一个GLES20.GL_TEXTURE0,并且OpenGL默认激活的就是第一个纹理单元。
另外,纹理单元GLES20.GL_TEXTURE1 = GLES20.GL_TEXTURE0 + 1,以此类推。

激活指定的纹理单元后,需要把它和纹理ID做绑定,并且在传递到着色器中的时候:GLES20.glUniform1i(mTextureHandler, 0),第二个参数索引需要和纹理单元索引保持一致。

到这里,可以发现,OpenGL方法的命名都是比较规律的,比如GLES20.glUniform1i对应的是GLSL中的uniform限定符变量;ES20.glGetAttribLocation对应GLSL中的attribute限定符变量等等

最后四行代码,用于配置纹理过滤模式和纹理环绕方式(对于这两个模式的介绍引用自【LearnOpenGL-CN】)

  • 纹理过滤模式

纹理坐标不依赖于分辨率,它可以是任意浮点值,所以OpenGL需要知道怎样将纹理像素映射到纹理坐标。

一般使用这两个模式:GL_NEAREST(邻近过滤)、GL_LINEAR(线性过滤)

当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。

当设置为GL_LINEAR的时候,它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。

来源LearnOpenGL-CN

  • 纹理环绕方式
环绕方式描述
GL_REPEAT对纹理的默认行为。重复纹理图像。
GL_MIRRORED_REPEAT和GL_REPEAT一样,但每次重复图片是镜像放置的。
GL_CLAMP_TO_EDGE纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。
GL_CLAMP_TO_BORDER超出的坐标为用户指定的边缘颜色。

来源LearnOpenGL-CN

4)绑定图片到纹理单元

激活了纹理单元以后,调用texImage2D方法,就可以把bmp绑定到指定的纹理单元上面了。

GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, mBitmap, 0)

5)绘制

绘制的时候,最后一句的最后一个参数由三角形的3个顶点变成为长方形的4个顶点。如果还是填入3,你会发现会显示图片的一半,即三角形(对角线分割开)。

GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)

至此,一张图片就通过纹理贴图显示出来了。

纹理贴图

当然,你会发现,这张图片是变形的,铺满整个GLSurfaceView窗口了。这里就涉及到了顶点坐标变换的问题了,将在下一篇文章中具体讲解。

五、总结

经过上面简单的绘制三角形和纹理贴图,可以总结出Android中OpenGL ES的2D绘制流程:

  1. 通过GLSurfaceView配置OpenGL ES版本,指定Render
  2. 实现GLSurfaceView.Renderer,复写暴露的方法,并配置OpenGL显示窗口,清屏
  3. 创建纹理ID
  4. 配置好顶点坐标和纹理坐标
  5. 初始化坐标变换矩阵
  6. 初始化OpenGL程序,并编译、链接顶点着色和片段着色器,获取GLSL中的变量属性
  7. 激活纹理单元,绑定纹理ID,配置纹理过滤模式和环绕方式
  8. 绑定纹理(如将bitmap绑定给纹理)
  9. 启动绘制

以上基本是一个通用的流程,当然渲染图片和渲染视频稍有不同,以及第5点,都将在下一篇说到。

六、参考文章

了解OpenGLES2.0

着色器语言GLSL

LearnOpenGL-CN