Android平台上基于OpenGl渲染yuv视频

6,060 阅读24分钟

更多博文,请看音视频系统学习的浪漫马车之总目录

前言

这是我音视频专栏的第一篇实例解析,也算是入门篇,重点讲下如何使用OpenGl去渲染一个yuv视频。

本篇博文涉及的知识点主要有三个:

1.yuv的概念

2.基于ndk进行C++程序的基本编写

3.OpenGl纹理的绘制

本文将重点讲知识点1和3,ndk开发部分就不细谈,由于OpenGl知识体系庞大,本文也是根据重点来分析,所以如果没有ndk开发基础和OpenGl基础的读者看本文可能会比较困难。

谈谈YUV

YUV,是一种颜色编码方法。常使用在各个影像处理组件中。Y”表示明亮度(Luminance、Luma),“U”和“V”则是色度、浓度(Chrominance、Chroma)相对我们都比较熟悉的编码格式RGB,RGB诉求于人眼对色彩的感应,YUV则着重于视觉对于亮度的敏感程度。 YUV在对照片或影片编码时,考虑到人类的感知能力,允许降低色度的带宽。换句话说,也就是编码的时候允许Y的量比UV要多,允许对图片的UV分量进行下采样,这样数据占用的空间就比RGB更小(关于下采样,简单来说就是以比原来更低的采样率进行采样。详细可以看下维基百科:Downsampling (signal processing)也可以看下知乎这篇文章:oversampling,undersampling,downsampling,upsampling 四个概念的区别和联系是什么?)。

图像中的Y, U,和V组成: 图像中的Y', U,和V组成

这样说有点抽象,可以看看微软这篇有名的文章进行理解:Video Rendering with 8-Bit YUV Formats

这里主要讲yuv的两个方面,分别是采样格式和存储格式。采样格式简单可以理解一张原图,每个像素怎么采样yuv各个分量,比如每隔几个像素采一个y分量(或者u、v)。存储格式简单来说就是采样之后,按照什么方式存储,比如哪个字节存储y,第几个字节存储u。

yuv采样格式:

文章里面“YUV Sampling”一节详细说明了各种不同格式的yuv是如何采样的。 以下是对该章节的节选翻译:

YUV的优点之一是,感知质量不会显著下降的前提下,色度通道的采样率与Y通道的采样率相比更低。一般用一个叫做A:B:C(即y:u:v)的符号用来描述U和V相对于Y的采样频率,为了方便理解,使用图来描述,图中y分量使用x表示,uv使用o表示:

4:4:4:

意味着色度通道没有向下采样,也就是说yuv三个通道都是全采样:

4:4:4

4:2:2:

表示2:1水平下采样,没有垂直下采样。每条扫描线包含四个Y样本对应两个U或V样本。也就是水平方向按照y:uv使用2:1进行采样,垂直方向全采样的方式:

4:2:2

4:2:0:

表示2:1水平下采样,2:1垂直下采样。也就是水平方向按照y:uv使用2:1进行采样,垂直方向按照y:uv使用2:1的方式:

4:2:0

注意这里4:2:0并不代表y:u:v = 4:2:0,这里指的是在每一行扫描时,只扫描一种色度分量(U 或者 V),和 Y 分量按照 2 : 1 的方式采样。比如,第一行扫描时,YU 按照 2 : 1 的方式采样,那么第二行扫描时,YV 分量按照 2:1 的方式采样。所以y和u或者v的比都是2:1。

4:1:1:

表示4:1水平下采样,没有垂直下采样。每条扫描线包含四个Y样本对应于每一个U或V样本。

4:1:1抽样比其他格式更少见,本文不详细讨论。

yuv存储格式:

YUV存储格式有两大类:planar 和 packed: packed:Y、U和V组件存储在一个数组中。每个像素点的Y,U,V是连续交错存储的。和RGB的存储格式类似。 planar :Y、U和V组件存储为三个独立的数组中。

y、u、v每个采样点使用8bit存储。

接下来详细讲下集中常见的yuv格式存储方式:

4:2:2格式:

主要有两种具体格式:

YUY2:

属于packed类型,YUY2格式,数据可视为unsigned char数组。第一个字节包含第一个Y样本,第二个字节包含第一U (Cb)样本,第三字节包含第二Y样本,第四个字节包含第V (Cr)样本,以此类推,如图: YUY2 可以看到,Y0 和 Y1 公用 U0 V0 分量,Y2 和 Y3 公用 U1 V1 分量,以此类推。

UYVY:

也是属于属于packed类型的,和YUY2和类似,只是存储方向是相反的: UYVY

4:2:0格式

该格式又包含多种存储方式,这里重点将以下几种:

YUV 420P 和 YUV 420SP 都是基于 Planar 平面模式 进行存储的,先存储所有的 Y 分量后, YUV420P 类型就会先存储所有的 U 分量或者 V 分量,而 YUV420SP 则是按照 UV 或者 VU 的交替顺序进行存储了,具体查看看下图(图来源于:音视频基础知识---像素格式YUV):

YUV420P:

(这里需要敲黑板,因为本文播放的yuv就是YUV420P格式,熟悉它的存储格式才可以理解代码中读取视频帧数据的逻辑) YUV420P 正是因为 YUV420P是2:1水平下采样,2:1垂直下采样,所以y分量数量等于视频宽高,u和v分量都是视频宽乘以高/4

YUV420SP

YUV420SP 4:2:0格式还有YV12、YU12、NV12 、NV21等存储格式,这里因为篇幅关系就不做细谈。

yuv转RGB:

目前一般解码后的视频格式为yuv,但是一般显卡渲染的格式是RGB,所以需要把yuv转化为RGB。

关于yuv转RGB这里有个公式可以知己使用: 在这里插入图片描述 或者直接用yuv的矩阵乘以以下矩阵得到对应的RGB矩阵: 在这里插入图片描述

yuv就先介绍到这里,熟悉yuv对于后面yuv视频播放至关重要。

谈谈OpenGl

OpenGL是行业领域中最为广泛接纳的 2D/3D 图形 API。OpenGL是一个跨平台的软件接口语言,用于调用硬件的2D、3D图形处理器。由于只是软件接口,所以具体底层实现依赖硬件设备制造商。

关于OpenGl的知识,可能写20篇博文也介绍不完,这里只介绍和当前播放yuv相关的,不会很详细,详细教程可以看这个网站:欢迎来到OpenGL的世界(以下描述也部分节选该网站)

安卓使用的是OpenGl ES版本,即OpenGL的一个子集,裁剪了一些功能,专门使用在嵌入式设备。

OpenGL图形渲染管线

首先要解释的是OpenGl的图形渲染管线:指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程。分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。

图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。

当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的相互独立的并行处理小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader),因为它们运行在GPU中,所以解放了CPU的省生产力

图形渲染管线的每个阶段的展示:

在这里插入图片描述

图形渲染管线的第一个部分是顶点着色器(Vertex Shader),它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标(坐标系统的转化),同时顶点着色器允许我们对顶点属性进行一些基本处理。顶点着色器代码是每个顶点执行一次。

图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入(如果是GL_POINTS,那么就是一个顶点),并所有的点装配成指定图元的形状。比如将顶点装配为三角形或者矩形。

几何着色器的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment,OpenGL渲染一个像素所需的所有数据)。

片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。片段着色器是每个片段(像素)执行一次

而我们要处理的,主要就是顶点着色器和片段着色器的代码逻辑,着色器是用叫GLSL的类C语言写成的,它包含一些针对向量和矩阵操作的有用特性。详细语法见着色器

OpenGL坐标系

要写顶点着色器代码,首先就要知道OpenGL顶点坐标系:

按照惯例,OpenGL是一个右手坐标系。简单来说,就是正x轴在你的右手边,正y轴朝上,而正z轴是朝向后方的。想象你的屏幕处于三个轴的中心,则正z轴穿过你的屏幕朝向你:

在这里插入图片描述

(这里要提的一点事,OpenGl在执行顶点着色器之后,会像流水线一样将坐标进行5个步骤的变换:局部坐标--世界坐标--观察坐标--裁剪坐标--屏幕坐标,这里因为实例是2D的,暂时还不需要关心这些)

现在需要记得的是,OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。所有在所谓的标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上(在这个范围以外的坐标都不会显示)。

2D情况下,既不考虑z轴,则一般来说顶点坐标系如下所示:

顶点坐标系

OpenGL纹理绘制

通过顶点着色器和片段着色器,我们可以指定要绘制的物体形状大小以及颜色,但是如果我们要做类似将一张图片绘制上去,该如何做呢?

OpenGl提供了纹理这个概念,让你可以将一张图片“贴”到你想要的位置。 (详细见 纹理

那么纹理是如何“贴”到图形上去的呢?其实就是对图片进行采样,再将采样到的颜色数据绘制到图形相应的位置。

为了能够把纹理映射(Map)到我们的图形上,我们需要指定图形的每个顶点各自对应纹理的哪个部分。所以图形的每个顶点都会关联一个纹理的坐标,用来标明该从纹理图像的哪个部分采样。

通俗来说,就是比方你顶点坐标提供的是一个矩形,现在要将一张图片(纹理)“贴”到矩形上,那么需要指定一个纹理坐标,告诉OpenGl矩形光栅化处理后的每个片段对应图片的哪个像素的颜色。纹理坐标,简单来说就是以一张纹理图片的某个点作为原点的坐标系。

类似下图所示: 在这里插入图片描述 由上图可以看到纹理坐标系的模样了,不过在Android平台,纹理坐标如下:

在这里插入图片描述

即以图片的左上角为原点的坐标系。

所以在提供了顶点坐标和纹理坐标之后,OpenGL就知道如何通过采样纹理上的像素的颜色数据,将颜色绘制到顶点坐标所表达的图形上的对应位置。

纹理就先讲到这里,还有许多具体的采样细节需要注意,还请看详细教程纹理

程序实例分析

所谓工欲善其事必先利其器,基础知识讲得差不多了,那么又要进入最重要的将代码环节了,这里使用的yuv格式为yuv420p

这里使用cmake进行构建,native-lib为项目自定义的动态库名称,其余需要链接的动态库如下配置:

find_library( # Sets the name of the path variable.
              log-lib

              # Specifies the name of the NDK library that
              # you want CMake to locate.
              log )

target_link_libraries( # Specifies the target library.
                       native-lib
                       GLESv2
                       EGL
                       android
                       # Links the target library to the log library
                       # included in the NDK.
                       ${log-lib} )

Java层首先创建一个集成GLSurfaceView的类:

public class YuvPlayer extends GLSurfaceView implements Runnable, SurfaceHolder.Callback, GLSurfaceView.Renderer {
	//这里将yuv视频文件放在sdcard目录中
    private final static String PATH = "/sdcard/sintel_640_360.yuv";

    public YuvPlayer(Context context, AttributeSet attrs) {
        super(context, attrs);
        setRenderer(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        new Thread(this).start();
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {

    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {

    }

    @Override
    public void run() {
        loadYuv(PATH,getHolder().getSurface());
    }
	//定义一个native方法加载yuv视频文件
    public native void loadYuv(String url, Object surface);

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {

    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {

    }

    @Override
    public void onDrawFrame(GL10 gl) {

    }
}

进入native层的loadYuv方法:

Java_com_example_yuvopengldemo_YuvPlayer_loadYuv(JNIEnv *env, jobject thiz, jstring jUrl,
                                                 jobject surface) {
    const char *url = env->GetStringUTFChars(jUrl, 0);
	//打开yuv视频文件	
    FILE *fp = fopen(url, "rb");
    if (!fp) {
    	//打Log方法
        LOGD("oepn file %s fail", url);
        return;
    }
    LOGD("open ulr is %s", url);

首先是从Java层传入的jstring变量转为char*,然后打开yuv视频文件。

接下来是初始化EGL:

这里简单解释下EGL是什么。

EGL™是Khronos呈现api(如OpenGL ES或OpenVG)与底层本机平台窗口系统之间的接口。它处理图形上下文管理、表面/缓冲区绑定和呈现同步,并使用其他Khronos api支持高性能、加速、混合模式的2D和3D呈现。EGL还提供了Khronos之间的互操作能力,以支持在api之间高效地传输数据——例如在运行OpenMAX AL的视频子系统和运行OpenGL ES的GPU之间。

通俗来讲就是,EGL是渲染API(如OpenGL, OpenGL ES, OpenVG)和本地窗口系统之间的接口。EGL可以理解为OpenGl ES ES和设备之间的桥梁,EGL是为OpenGl提供绘制表面的。因为OpenGl是跨平台的,当它访问不同平台的设备的时候需要EGL作为中间的适配器。

在这里插入图片描述 EGL的使用步骤: 在这里插入图片描述 具体的代码:

//1.获取原始窗口
ANativeWindow *nwin = ANativeWindow_fromSurface(env, surface);
    //获取OpenGl ES的渲染目标。Display(EGLDisplay) 是对实际显示设备的抽象。
    EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
    if (display == EGL_NO_DISPLAY) {
        LOGD("egl display failed");
        return;
    }
    //2.初始化egl与 EGLDisplay 之间的连接,后两个参数为主次版本号
    if (EGL_TRUE != eglInitialize(display, 0, 0)) {
        LOGD("eglInitialize failed");
        return;
    }
	//创建渲染用的surface
    //2.1 surface配置
    EGLConfig eglConfig;
    EGLint configNum;
    EGLint configSpec[] = {
            EGL_RED_SIZE, 8,
            EGL_GREEN_SIZE, 8,
            EGL_BLUE_SIZE, 8,
            EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
            EGL_NONE
    };

    if (EGL_TRUE != eglChooseConfig(display, configSpec, &eglConfig, 1, &configNum)) {
        LOGD("eglChooseConfig failed");
        return;
    }

    //2.2创建surface(将egl和NativeWindow进行关联,即将EGl和设备屏幕连接起来。最后一个参数为属性信息,0表示默认版本)。Surface(EGLSurface)是对用来存储图像的内存区FrameBuffer 的抽象。这就是我们要渲染的Surface
    EGLSurface winSurface = eglCreateWindowSurface(display, eglConfig, nwin, 0);
    if (winSurface == EGL_NO_SURFACE) {
        LOGD("eglCreateWindowSurface failed");
        return;
    }

    //3 创建关联上下文
    const EGLint ctxAttr[] = {
            EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE
    };
    //创建egl关联OpenGl的上下文环境 EGLContext 实例。EGL_NO_CONTEXT表示不需要多个设备共享上下文。Context (EGLContext) 存储 OpenGL ES绘图的一些状态信息。上面的代码只是egl和设备窗口的关联,这里是和OpenGl的关联
    EGLContext context = eglCreateContext(display, eglConfig, EGL_NO_CONTEXT, ctxAttr);
    if (context == EGL_NO_CONTEXT) {
        LOGD("eglCreateContext failed");
        return;
    }
    //将EGLContext和opengl真正关联起来。绑定该线程的显示设备及上下文
    //两个surface一个读一个写。
    if (EGL_TRUE != eglMakeCurrent(display, winSurface, winSurface, context)) {
        LOGD("eglMakeCurrent failed");
        return;
    }

创建初始化EGL,接下来就是真正的OpenGl绘制代码。

先看下着色器代码。看着色器代码之前,先了解下GLSL一些基础: 常见的变量类型: attritude:一般用于各个顶点各不相同的量。如顶点位置、纹理坐标、法向量、颜色等等。 uniform:一般用于对于物体中所有顶点或者所有的片段都相同的量。比如光源位置、统一变换矩阵、颜色等。 varying:表示易变量,一般用于顶点着色器传递到片段着色器的量。 vec2:包含了2个浮点数的向量 vec3:包含了3个浮点数的向量 vec4:包含了4个浮点数的向量 sampler1D:1D纹理着色器 sampler2D:2D纹理着色器 sampler3D:3D纹理着色器

首先编写顶点着色器代码:

//顶点着色器,每个顶点执行一次,可以并行执行
#define GET_STR(x) #x
static const char *vertexShader = GET_STR(
        attribute vec4 aPosition;//输入的顶点坐标,会在程序指定将数据输入到该字段
        attribute vec2 aTextCoord;//输入的纹理坐标,会在程序指定将数据输入到该字段
        varying vec2 vTextCoord;//输出的纹理坐标,输入到片段着色器
        void main() {
            //这里其实是将上下翻转过来(因为安卓图片会自动上下翻转,所以转回来。也可以在顶点坐标中就上下翻转)
            vTextCoord = vec2(aTextCoord.x, 1.0 - aTextCoord.y);
            //直接把传入的坐标值作为传入渲染管线。gl_Position是OpenGL内置的
            gl_Position = aPosition;
        }
);

这里逻辑很简单。使用两个attribute变量,一个接受顶点坐标,一个接收纹理坐标,这里以标准的OpenGl的纹理坐标为标准,即和安卓平台是上下翻转关系的(在本文OpenGL纹理绘制一节有说到),所以对传进来的纹理坐标在0.0~1。0之间进行上下翻转,再赋值给varying类型变量vTextCoord,vTextCoord将通过渲染管线传给片段着色器。最后将传进来的顶点坐标赋值给gl_Position ,gl_Position 是OpenGL内置的表示顶点坐标的变量。gl_Position 被赋值之后,将通过渲染管线传给后面的阶段,在图元装配的时候,将顶点连接起来。在光栅化图元的时候,将两个顶点之间的线段分解成大量的小片段,varying数据在这个过程中计算生成,记录在每个片段中,之后传递给片段着色器

然后编写片段着色器代码:

//图元被光栅化为多少片段,就被调用多少次
static const char *fragYUV420P = GET_STR(
        precision mediump float;
        //接收从顶点着色器、光栅化处理传来的纹理坐标数据
   	    varying vec2 vTextCoord;
        //输入的yuv三个纹理
        uniform sampler2D yTexture;//y分量纹理
        uniform sampler2D uTexture;//u分量纹理
        uniform sampler2D vTexture;//v分量纹理
        void main() {
        	//存放采样之后的yuv数据
            vec3 yuv;
            //存放yuv数据转化后的rgb数据
            vec3 rgb;
            //对yuv各个分量对应vTextCoord的像素进行采样。这里texture2D得到的结果是一个vec4变量,它的r、g、b、a的值都为采样到的那个分量的值
            //将采样到的y、u、v分量的数据分别保存在vec3 yuv的r、g、b(或者x、y、z)分量
     	    yuv.r = texture2D(yTexture, vTextCoord).g;
            yuv.g = texture2D(uTexture, vTextCoord).g - 0.5;
            yuv.b = texture2D(vTexture, vTextCoord).g - 0.5;
            //这里必须把yuv转化为RGB
            rgb = mat3(
                    1.0, 1.0, 1.0,
                    0.0, -0.39465, 2.03211,
                    1.13983, -0.5806, 0.0
            ) * yuv;
            //gl_FragColor是OpenGL内置的,将rgb数据赋值给gl_FragColor,传到渲染管线的下一阶段 ,gl_FragColor 表示正在呈现的像素的 R、G、B、A 值。 
            gl_FragColor = vec4(rgb, 1.0);
        }
);

这里要将yuv三个分量分别用三层纹理来渲染,然后将多层纹理混合一起显示。代码中三个sampler2D类型变量就是纹理图片,需要从外部程序传入。然后通过texture2D方法采样得到对应纹理坐标位置的颜色数据,将yuv三个分量的采样值放入vec3 类型变量yuv的三个分量中,因为OpenGl只支持RGB的渲染,所以需要将vec3类型的 yuv通过公式转为一个rgb 的vec3 类型变量。最后将rgb 变量构建一个vec4变量,作为最终颜色赋值给gl_FragColor 。

着色器代码定义完,接下来就是渲染逻辑部分。

首先是将前面的定义的着色器加载、编译以及创建、链接、激活着色器程序:

GLint vsh = initShader(vertexShader, GL_VERTEX_SHADER);
    GLint fsh = initShader(fragYUV420P, GL_FRAGMENT_SHADER);

    //创建渲染程序
    GLint program = glCreateProgram();
    if (program == 0) {
        LOGD("glCreateProgram failed");
        return;
    }

    //向渲染程序中加入着色器
    glAttachShader(program, vsh);
    glAttachShader(program, fsh);

    //链接程序
    glLinkProgram(program);
    GLint status = 0;
    glGetProgramiv(program, GL_LINK_STATUS, &status);
    if (status == 0) {
        LOGD("glLinkProgram failed");
        return;
    }
    LOGD("glLinkProgram success");
    //激活渲染程序
    glUseProgram(program);

其中initShader函数:

GLint initShader(const char *source, GLint type) {
    //创建shader
    GLint sh = glCreateShader(type);
    if (sh == 0) {
        LOGD("glCreateShader %d failed", type);
        return 0;
    }
    //加载shader
    glShaderSource(sh,
                   1,//shader数量
                   &source,
                   0);//代码长度,传0则读到字符串结尾

    //编译shader
    glCompileShader(sh);

    GLint status;
    glGetShaderiv(sh, GL_COMPILE_STATUS, &status);
    if (status == 0) {
        LOGD("glCompileShader %d failed", type);
        LOGD("source %s", source);
        return 0;
    }

    LOGD("glCompileShader %d success", type);
    return sh;
}

传入顶点坐标数组给顶点着色器:

//加入三维顶点数据。这里就是整个屏幕的矩形。
    static float ver[] = {
            1.0f, -1.0f, 0.0f,
            -1.0f, -1.0f, 0.0f,
            1.0f, 1.0f, 0.0f,
            -1.0f, 1.0f, 0.0f
    };
	//获取顶点着色器的aPosition属性引用
    GLuint apos = static_cast<GLuint>(glGetAttribLocation(program, "aPosition"));
    glEnableVertexAttribArray(apos);
    //将顶点坐标传入顶点着色器的aPosition属性
    //各个参数意义:apos:顶点着色器中aPosition变量的引用。3表示数组中三个数字表示一个顶点。GL_FLOAT表示数据类型是浮点数。
    //GL_FALSE表示不进行归一化。0表示stride(跨距),在数组表示多种属性的时候使用到,这里因为这有一个属性,设置为0即可。ver表示所传入的顶点数组地址
    glVertexAttribPointer(apos, 3, GL_FLOAT, GL_FALSE, 0, ver);

(习惯了Java开发的同学恐怕看到这种代码很不习惯吧??)

传入纹理坐标数组给顶点着色器:

//加入纹理坐标数据,这里是整个纹理。
    static float fragment[] = {
            1.0f, 0.0f,
            0.0f, 0.0f,
            1.0f, 1.0f,
            0.0f, 1.0f
    };
    ////将纹理坐标数组传入顶点着色器的aTextCoord属性
    GLuint aTex = static_cast<GLuint>(glGetAttribLocation(program, "aTextCoord"));
    glEnableVertexAttribArray(aTex);
    //各个参数意义:aTex :顶点着色器中aTextCoord变量的引用。2表示数组中三个数字表示一个顶点。GL_FLOAT表示数据类型是浮点数。
    //GL_FALSE表示不进行归一化。表示stride(跨距),在数组表示多种属性的时候使用到,这里因为这有一个属性,设置为0即可。fragment表示所传入的顶点数组地址
    glVertexAttribPointer(aTex, 2, GL_FLOAT, GL_FALSE, 0, fragment);

如果能把传入顶点坐标数组给顶点着色器理解,这一段就没有什么难度了。

接着是纹理对象的处理:

这里要讲一下几个概念:纹理对象、纹理目标、纹理单元 1.纹理对象是我们创建的用来存储纹理的显存,在实际使用过程中使用的是创建后返回的纹理ID。 2.纹理目标可以简单理解为纹理的类型,比如指定是渲染2D还是3D等。 3.纹理单元:纹理的操作容器,有GL_TEXTURE0、GL_TEXTURE1、GL_TEXTURE2等,纹理单元的数量是有限的,最多16个。 所以在最多只能同时操作16个纹理。可以简单理解为第几层纹理。

创建纹理对象:

   //指定纹理变量在哪一层纹理单元渲染
   glUniform1i(glGetUniformLocation(program, "yTexture"), GL_TEXTURE0);
    glUniform1i(glGetUniformLocation(program, "uTexture"), GL_TEXTURE1);
    glUniform1i(glGetUniformLocation(program, "vTexture"), GL_TEXTURE2);
    //纹理ID
    GLuint texts[3] = {0};
    //创建3个纹理对象,并且得到各自的纹理ID。之后对纹理的操作就可以通过该纹理ID进行。
    glGenTextures(3, texts);

将纹理对象和相应的纹理目标进行绑定:

//yuv视频宽高
int width = 640;
int height = 360;
//通过 glBindTexture 函数将纹理目标和以texts[0]为ID的纹理对象绑定后,对纹理目标所进行的操作都反映到该纹理对象上
    glBindTexture(GL_TEXTURE_2D, texts[0]);
    //缩小的过滤器(关于过滤详细可见 [纹理](https://learnopengl-cn.github.io/01%20Getting%20started/06%20Textures/))
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    //放大的过滤器
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    //设置纹理的格式和大小
    // 当前绑定的纹理对象就会被渲染上纹理
     glTexImage2D(GL_TEXTURE_2D,
                 0,//指定要Mipmap的等级
                 GL_LUMINANCE,//gpu内部格式,告诉OpenGL内部用什么格式存储和使用这个纹理数据。 亮度,灰度图(这里就是只取一个亮度的颜色通道的意思,因这里只取yuv其中一个分量)
                 width,//加载的纹理宽度。最好为2的次幂
                 height,//加载的纹理高度。最好为2的次幂
                 0,//纹理边框
                 GL_LUMINANCE,//数据的像素格式 亮度,灰度图
                 GL_UNSIGNED_BYTE,//一个像素点存储的数据类型
                 NULL //纹理的数据(先不传,等后面每一帧刷新的时候传)
    );

这里要注意视频的宽高一定设置正确,不然渲染的数据就都是错误的。

这里要说明下glTexImage2D第三个参数,告诉OpenGL内部用什么格式存储和使用这个纹理数据(一个像素包含多少个颜色成分,是否压缩)。常用的常量如下:

在这里插入图片描述

这里yuv三个分量的代码都是一样的,只是传入的宽高不同,对于u和v来说,宽高各位视频宽高的二分之一:

//设置纹理的格式和大小
    glTexImage2D(GL_TEXTURE_2D,
                 0,//细节基本 默认0
                 GL_LUMINANCE,//gpu内部格式 亮度,灰度图(这里就是只取一个颜色通道的意思)
                 width / 2,
                 height / 2,//v数据数量为屏幕的4分之1
                 0,//边框
                 GL_LUMINANCE,//数据的像素格式 亮度,灰度图
                 GL_UNSIGNED_BYTE,//像素点存储的数据类型
                 NULL //纹理的数据(先不传)
    );

为什么是width / 2,height / 2呢?还记得上文说过的yuv420p的采样和存储格式么? YUV420P是2:1水平下采样,2:1垂直下采样,所以y分量数量等于视频宽乘以高,u和v分量都是视频宽/2乘以高/2。

从视频文件中读取yuv数据到内存中:

	unsigned char *buf[3] = {0};
    buf[0] = new unsigned char[width * height];//y
    buf[1] = new unsigned char[width * height / 4];//u
    buf[2] = new unsigned char[width * height / 4];//v
	//循环读出每一帧
    for (int i = 0; i < 10000; ++i) {
        //读一帧yuv420p数据
        if (feof(fp) == 0) {
        	//读取y数据
            fread(buf[0], 1, width * height, fp);
            //读取u数据
            fread(buf[1], 1, width * height / 4, fp);
            //读取v数据
            fread(buf[2], 1, width * height / 4, fp);
        }

还是回顾刚才敲黑板的地方,由图可得yuv420p中,是先存储视频宽高个y元素,再存储视频宽乘以高/4个u,再存储视频宽乘以高/4个v,所以for循环中读取一帧才按照yuv的顺序和数量依次读到内存的数组中。 在这里插入图片描述 在读出一帧后,更新数据到纹理对象上。 buf[0]即y分量的数据渲染到纹理上:

//激活第一层纹理,绑定到创建的纹理
      
        glActiveTexture(GL_TEXTURE0);
        //绑定y对应的纹理
        glBindTexture(GL_TEXTURE_2D, texts[0]);
        //替换纹理,比重新使用glTexImage2D性能高多
        glTexSubImage2D(GL_TEXTURE_2D, 0,
                        0, 0,//相对原来的纹理的offset
                        width, height,//加载的纹理宽度、高度。最好为2的次幂
                        GL_LUMINANCE, GL_UNSIGNED_BYTE,
                        buf[0]);

u和v也是一样,只是宽高换为width / 2, height / 2。

最后将画面显示出来:

glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
//窗口显示,交换双缓冲区
eglSwapBuffers(display, winSurface);

如此循环,就将每一帧渲染出来,也就播放了yuv视频:

这里我使用ffmpeg命令将《龙猫》中截取10秒的视频转化为yuv,录屏的gif不知为何总是上传不了,所以这里只上传了一张截图 = = 。 在这里插入图片描述

虽然只是10秒的视频,但是已经超过github的最大上传量,所以视频没有上传。各位如果需要可以自己用ffmpeg命令转换任何一个格式支持视频文件为yuv420p格式来运行。

接触音视频开发领域时间不长,如有错误疏漏,请各位指正~

项目地址:YuvVideoPlayerDemo

介绍一个自己刚出炉的音视频播放录制开源项目

参考文献:

learnopengl

Video Rendering with 8-Bit YUV Formats

音视频基础知识---像素格式YUV

《OpenGl超级宝典 第五版》

Android OpenGL ES 视频应用开发教程目录

Android 自定义相机开发(三) —— 了解下EGL