本文是基于前面两篇OpenGl理论学习的实际应用,更好的巩固一下前面的学习内容,重点讲下如何使用OpenGl去渲染一个yuv格式视频。
什么是YUV
YUV,是一种颜色编码方法。常使用在各个影像处理组件中。“Y”表示明亮度,“U”和“V”则是色度、浓度相对我们都比较熟悉的编码格式RGB,RGB诉求于人眼对色彩的感应,YUV则着重于视觉对于亮度的敏感程度。 一般的视频采集芯片输出的码流一般都是 YUV 格式数据流,后续视频处理也是对 YUV 数据流进行编码和解析。了解YUV 数据流对做视频领域的人十分重要,同时为了我们后续的ffmpeg编解码工具的学习做铺垫,所以我们可以先学习如何使用OpenGl去渲染yuv格式视频。
为什么需要YUV
RGB 三原色分别表示红(R)、绿(G)、蓝(B),我们是将它们以不同的比例叠加,来产生不同的颜色。 比如一张 1920 * 1280 的图片,代表着有 1920 * 1280 个像素点。如果采用 RGB 编码方式,每个像素点都有红、绿、蓝三个原色,其中每个原色占用 3 个字节,每个像素占用 24 个位。 那么,一张 1920 * 1280 大小的图片,就占用 1920 * 1280 * 3 Byte / 1024 / 1024 = 7.03MB 存储空间。而YUV在对照片或影片编码时,考虑到人类的感知能力,允许降低色度的带宽。所以,并不是每个像素点都需要包含了 Y、U、V 三个分量,根据不同的采样格式,可以每个 Y 分量都对应自己的 UV 分量,也可以几个 Y 分量共用 UV 分量。相比 RGB,能够节约不少存储空间。
YUV 采样格式
YUV 图像的主流采样方式有如下三种:
- YUV 4:4:4 采样 表示 Y、U、V 三分量采样率相同,即每个像素的三分量信息完整,都是 8bit,每个像素占用 3 个字节。
四个像素为: [Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]
采样的码流为: Y0 U0 V0 Y1 U1 V1 Y2 U2 V2 Y3 U3 V3
映射出的像素点为:[Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]
- YUV 4:2:2 采样 表示 UV 分量的采样率是 Y 分量的一半。如下图
四个像素为: [Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]
采样的码流为: Y0 U0 Y1 V1 Y2 U2 Y3 U3
映射出的像素点为:[Y0 U0 V1]、[Y1 U0 V1]、[Y2 U2 V3]、[Y3 U2 V3]
- YUV 4:2:0 采样 并不意味着不采样 V 分量。它指的是对每条扫描线来说,只有一种色度分量以 2:1 的采样率存储,相邻的扫描行存储不同的色度分量。也就是说,如果第一行是 4:2:0,下一行就是 4:0:2,在下一行就是 4:2:0,以此类推。
图像像素为:
[Y0 U0 V0]、[Y1 U1 V1]、 [Y2 U2 V2]、 [Y3 U3 V3]
[Y5 U5 V5]、[Y6 U6 V6]、 [Y7 U7 V7] 、[Y8 U8 V8]
采样的码流为:
Y0 U0 Y1 Y2 U2 Y3
Y5 V5 Y6 Y7 V7 Y8
映射出的像素点为:
[Y0 U0 V5]、[Y1 U0 V5]、[Y2 U2 V7]、[Y3 U2 V7]
[Y5 U0 V5]、[Y6 U0 V5]、[Y7 U2 V7]、[Y8 U2 V7]
其中,每采样一个像素点,都会采样 Y 分量,而 U、V 分量都会隔行按照 2:1 进行采样。 一张 1920 * 1280 大小的图片,采用 YUV 4:2:0 采样时的大小为: (1920 * 1280 * 8bit 1920 * 1280 * 0.25 * 8bit * 2 ) / 8 / 1024 / 1024 = 3.51M 相比 RGB,节省了一半的存储空间。
OpenGL图形渲染管线
我们前文已经学习过OpenGL图形渲染管道的工作过程如图所示,主要分成两个部分。第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。
简单来说分为下面几步:
1.顶点着色器(Vertex Shader)把一个单独的顶点作为输入,经过多次矩阵变换,转化为OpenGL NDC坐标系中对应的位置。
2.图元装阶段将顶点着色器输出的所有顶点作为输入,将所有的点装配成指定图元的形状。比如将顶点装配为三角形或者矩形。
3.几何着色器的输出会被传入光栅化阶段,这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器使用的片段。片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。
OpenGL坐标系
OpenGL坐标系的变换,笔者前面已经做过相关介绍,有兴趣的读者可以回顾一下 Android OpenGL 介绍和工作流程(十)。
本文就不再展开详细叙述。但是我们要记住几个关键的地方:
(1)OpenGL是一个右手坐标系。
(2)OpenGl在执行顶点着色器之后,顶点变换顺序:局部坐标--世界坐标--观察坐标--裁剪坐标--屏幕坐标
(3)OpenGL只有3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才会处理。也就是说顶点必须在标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上,在这个范围以外的坐标都不会显示。
因为本章视频渲染的是2D视频,所以不考虑z轴,2D顶点坐标系如下所示:
OpenGL 2D视频渲染原理:
因为视频是图片的集合,所以我们使用OpenGL渲染视频的时候,可以想象成我们在OpenGL标准化设备坐标系确定了4个顶点坐标,然后通过快速替换这个四个顶点确定的2D平面的纹理来实现图片的切换,达到视频播放的效果。在这里我们也需要了解一下OpenGL的纹理知识。
OpenGL纹理绘制
OpenGl提供了纹理概念,将一张图片贴到任意位置。 实际就是对图片进行采样,再将采样到的颜色数据绘制到图形相应的位置。 为了能够把纹理映射(Map)到我们的图形上,我们需要指定图形的每个顶点各自对应纹理的哪个部分。所以图形的每个顶点都会关联一个纹理的坐标,用来标明该从纹理图像的哪个部分采样。 通俗来说,就是比方你顶点坐标提供的是一个矩形,现在要将一张图片“贴”到矩形上,那么需要指定一个纹理坐标,告诉OpenGl矩形光栅化处理后的每个片段对应图片的哪个像素的颜色。纹理坐标,简单来说就是以一张纹理图片的某个点作为原点的坐标系。 如下图
由上图可以看到纹理坐标系,不过在Android平台,图片的左上角为原点的坐标系,纹理坐标如下:
我们在提供了顶点坐标和纹理坐标之后,OpenGL就知道如何通过采样纹理上的像素的颜色数据,将颜色绘制到顶点坐标所表达的图形上的对应位置。
程序源码分析
在有了前文的理论基础之后,我们可以动手通过实践来体验一下,OpenGL视频渲染的实践效果。
1.在Android Studio创建C/C 工程,配置CMakeLists.txt。
代码语言:javascript复制
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} )
2.创建集成GLSurfaceView的播放类。
代码语言:javascript复制public class YuvPlayer extends GLSurfaceView implements Runnable, SurfaceHolder.Callback, GLSurfaceView.Renderer {
Context context;
public YuvPlayer(Context context, AttributeSet attrs) {
super(context, attrs);
setRenderer(this);
this.context = context;
}
@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(context.getExternalCacheDir() "/out.yuv", getHolder().getSurface());
}
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) {
}
}
其中out.yuv文件就是我们需要使用OpenGL渲染的文件。这里补充一下使用在mac os下面ffmpeg生成yuv文件的方法。
(1)在mac os 安装ffmpeg。
代码语言:javascript复制brew install FFmpeg
(2)安装完成之后,在本地加载一个mp4视频,使用ffmpeg命令转化为yuv视频文件。
代码语言:javascript复制ffmpeg -i input.mp4 -s 640*272 -r 15 -pix_fmt yuv420p out.yuv
如下图所示:
(3)使用ffmpeg命令播放yuv视频,验证是否播放成功。
代码语言:javascript复制ffplay -f rawvideo -video_size 640x272 out.yuv
如下图所示
注意:其中需要特别注意参数640*272,是设置视频的分辨率,编码命令设置的分辨率必须和播放命令的分辨率统一才能播放验证成功。本文最后会附上demo下载链接,我们在完成demo编译之后,需要把生成的yuv文件放在路径下面:
/Android/data/com.pengjie0668.yuvopengldemo/cache/out.yuv,项目OpenGL demo才能正常渲染视频。
3.OpenGl视频渲染流程。
1.获取原始窗口
代码语言:javascript复制 ANativeWindow *nwin = ANativeWindow_fromSurface(env, surface);
2.初始化egl,后两个参数为主次版本号。EGL是渲染API(如OpenGL, OpenGL ES, OpenVG)和本地窗口系统之间的接口。EGL可以理解为OpenGl ES ES和设备之间的桥梁,EGL是为OpenGl提供绘制表面的。因为OpenGl是跨平台的,当它访问不同平台的设备的时候需要EGL作为中间的适配器。
代码语言:javascript复制 if (EGL_TRUE != eglInitialize(display, 0, 0)) {
LOGD("eglInitialize failed");
return;
}
3. 创建surface,进行egl和NativeWindow关联。
代码语言:javascript复制 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;
}
EGLSurface winSurface = eglCreateWindowSurface(display, eglConfig, nwin, 0);
if (winSurface == EGL_NO_SURFACE) {
LOGD("eglCreateWindowSurface failed");
return;
}
4.创建关联上下文。
代码语言:javascript复制 const EGLint ctxAttr[] = {
EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE
};
//EGL_NO_CONTEXT表示不需要多个设备共享上下文
EGLContext context = eglCreateContext(display, eglConfig, EGL_NO_CONTEXT, ctxAttr);
if (context == EGL_NO_CONTEXT) {
LOGD("eglCreateContext failed");
return;
}
//将egl和opengl关联
//两个surface一个读一个写。第二个一般用来离线渲染?
if (EGL_TRUE != eglMakeCurrent(display, winSurface, winSurface, context)) {
LOGD("eglMakeCurrent failed");
return;
}
GLint vsh = initShader(vertexShader, GL_VERTEX_SHADER);
GLint fsh = initShader(fragYUV420P, GL_FRAGMENT_SHADER);
5.创建渲染程序,向渲染程序中加入着色器,链接程序,激活渲染程序。
代码语言:javascript复制 //创建渲染程序
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);
6.加入三维顶点坐标
代码语言:javascript复制 //加入三维顶点数据
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
};
GLuint apos = static_cast<GLuint>(glGetAttribLocation(program, "aPosition"));
glEnableVertexAttribArray(apos);
glVertexAttribPointer(apos, 3, GL_FLOAT, GL_FALSE, 0, ver);
7.加入纹理坐标数据
代码语言:javascript复制 //加入纹理坐标数据
static float fragment[] = {
1.0f, 0.0f,
0.0f, 0.0f,
1.0f, 1.0f,
0.0f, 1.0f
};
GLuint aTex = static_cast<GLuint>(glGetAttribLocation(program, "aTextCoord"));
glEnableVertexAttribArray(aTex);
glVertexAttribPointer(aTex, 2, GL_FLOAT, GL_FALSE, 0, fragment);
8.重点讲一下纹理对象的处理。首先纹理对象是我们创建的用来存储纹理的显存,在实际使用过程中使用的是创建后返回的纹理ID。
代码语言:javascript复制 //对sampler变量,使用函数glUniform1i和glUniform1iv进行设置
glUniform1i(glGetUniformLocation(program, "yTexture"), 0);
glUniform1i(glGetUniformLocation(program, "uTexture"), 1);
glUniform1i(glGetUniformLocation(program, "vTexture"), 2);
//纹理ID
GLuint texts[3] = {0};
//创建若干个纹理对象,并且得到纹理ID
glGenTextures(3, texts);
8.将纹理对象和相应的纹理目标进行绑定。
要注意视频的宽高一定设置正确,不然渲染的数据就都是错误的。这里的宽高是前文用ffmpeg命令把mp4文件转编为yuv文件时设置的宽高。
代码语言:javascript复制//yuv视频宽高
int width = 640;
int height = 360;
//通过 glBindTexture 函数将纹理目标和以texts[0]为ID的纹理对象绑定后,对纹理目标所进行的操作都反映到该纹理对象上
glBindTexture(GL_TEXTURE_2D, texts[0]);
//缩小的过滤器(关于过滤详细可见 [纹理](https://learnopengl-cn.github.io/01 Getting started/06 Textures/))
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 //纹理的数据(先不传,等后面每一帧刷新的时候传)
);
8.从视频文件中读取yuv数据到内存中
代码语言:javascript复制 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) {
fread(buf[0], 1, width * height, fp);
fread(buf[1], 1, width * height / 4, fp);
fread(buf[2], 1, width * height / 4, fp);
}
9.最后是画面显示:
代码语言:javascript复制 glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
//窗口显示,交换双缓冲区
eglSwapBuffers(display, winSurface);
Demo效果展示
本章最后附上demo源码,编译运行之后结果如图所示,因为github上传文件大小限制,所以笔者没有把yuv视频上传,需要读者自行转化,本文内已经介绍过如何使用ffmpeg工具把mp4文件转化为yuv文件。
Github Demo下载链接