本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
更多音视频开发文章,请看:音视频开发专栏
介绍一个自己刚出炉的音视频播放录制开源项目
这是我音视频专栏的第一篇实例解析,也算是入门篇,重点讲下如何使用OpenGl去渲染一个yuv视频。
本篇博文涉及的知识点主要有三个:
1.yuv的概念
2.基于ndk进行C++程序的基本编写
3.OpenGl纹理的绘制
本文将重点讲知识点1和3,ndk开发部分就不细谈,由于OpenGl知识体系庞大,本文也是根据重点来分析,所以如果没有ndk开发基础和OpenGl基础的读者看本文可能会比较困难。
YUV,是一种颜色编码方法。常使用在各个影像处理组件中。Y”表示明亮度(Luminance、Luma),“U”和“V”则是色度、浓度(Chrominance、Chroma)相对我们都比较熟悉的编码格式RGB,RGB诉求于人眼对色彩的感应,YUV则着重于视觉对于亮度的敏感程度。 YUV在对照片或影片编码时,考虑到人类的感知能力,允许降低色度的带宽。换句话说,也就是编码的时候允许Y的量比UV要多,允许对图片的UV分量进行下采样,这样数据占用的空间就比RGB更小(关于下采样,简单来说就是以比原来更低的采样率进行采样。详细可以看下维基百科:Downsampling (signal processing)也可以看下知乎这篇文章:oversampling,undersampling,downsampling,upsampling 四个概念的区别和联系是什么?)。
这样说有点抽象,可以看看微软这篇有名的文章进行理解:Video Rendering with 8-Bit YUV Formats
这里主要讲yuv的两个方面,分别是采样格式和存储格式。采样格式简单可以理解一张原图,每个像素怎么采样yuv各个分量,比如每隔几个像素采一个y分量(或者u、v)。存储格式简单来说就是采样之后,按照什么方式存储,比如哪个字节存储y,第几个字节存储u。
文章里面“YUV Sampling”一节详细说明了各种不同格式的yuv是如何采样的。
以下是对该章节的节选翻译:
YUV的优点之一是,感知质量不会显著下降的前提下,色度通道的采样率与Y通道的采样率相比更低。一般用一个叫做A:B:C(即y:u:v)的符号用来描述U和V相对于Y的采样频率,为了方便理解,使用图来描述,图中y分量使用x表示,uv使用o表示:
意味着色度通道没有向下采样,也就是说yuv三个通道都是全采样:
表示2:1水平下采样,没有垂直下采样。每条扫描线包含四个Y样本对应两个U或V样本。也就是水平方向按照y:uv使用2:1进行采样,垂直方向全采样的方式:
表示2:1水平下采样,2:1垂直下采样。也就是水平方向按照y:uv使用2:1进行采样,垂直方向按照y:uv使用2:1的方式:
注意这里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水平下采样,没有垂直下采样。每条扫描线包含四个Y样本对应于每一个U或V样本。
4:1:1抽样比其他格式更少见,本文不详细讨论。
YUV存储格式有两大类:planar 和 packed:
packed:Y、U和V组件存储在一个数组中。每个像素点的Y,U,V是连续交错存储的。和RGB的存储格式类似。
planar :Y、U和V组件存储为三个独立的数组中。
y、u、v每个采样点使用8bit存储。
接下来详细讲下集中常见的yuv格式存储方式:
主要有两种具体格式:
属于packed类型,YUY2格式,数据可视为unsigned char数组。第一个字节包含第一个Y样本,第二个字节包含第一U (Cb)样本,第三字节包含第二Y样本,第四个字节包含第V (Cr)样本,以此类推,如图:
可以看到,Y0 和 Y1 公用 U0 V0 分量,Y2 和 Y3 公用 U1 V1 分量,以此类推。
也是属于属于packed类型的,和YUY2和类似,只是存储方向是相反的:
该格式又包含多种存储方式,这里重点将以下几种:
YUV 420P 和 YUV 420SP 都是基于 Planar 平面模式 进行存储的,先存储所有的 Y 分量后, YUV420P 类型就会先存储所有的 U 分量或者 V 分量,而 YUV420SP 则是按照 UV 或者 VU 的交替顺序进行存储了,具体查看看下图(图来源于:音视频基础知识—像素格式YUV):
(这里需要敲黑板,因为本文播放的yuv就是YUV420P格式,熟悉它的存储格式才可以理解代码中读取视频帧数据的逻辑)
正是因为 YUV420P是2:1水平下采样,2:1垂直下采样,所以y分量数量等于视频宽高,u和v分量都是视频宽乘以高/4
4:2:0格式还有YV12、YU12、NV12 、NV21等存储格式,这里因为篇幅关系就不做细谈。
目前一般解码后的视频格式为yuv,但是一般显卡渲染的格式是RGB,所以需要把yuv转化为RGB。
关于yuv转RGB这里有个公式可以知己使用:
或者直接用yuv的矩阵乘以以下矩阵得到对应的RGB矩阵:
yuv就先介绍到这里,熟悉yuv对于后面yuv视频播放至关重要。
OpenGL是行业领域中最为广泛接纳的 2D/3D 图形 API。OpenGL是一个跨平台的软件接口语言,用于调用硬件的2D、3D图形处理器。由于只是软件接口,所以具体底层实现依赖硬件设备制造商。
关于OpenGl的知识,可能写20篇博文也介绍不完,这里只介绍和当前播放yuv相关的,不会很详细,详细教程可以看这个网站:欢迎来到OpenGL的世界(以下描述也部分节选该网站)
安卓使用的是OpenGl ES版本,即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是一个右手坐标系。简单来说,就是正x轴在你的右手边,正y轴朝上,而正z轴是朝向后方的。想象你的屏幕处于三个轴的中心,则正z轴穿过你的屏幕朝向你:
(这里要提的一点事,OpenGl在执行顶点着色器之后,会像流水线一样将坐标进行5个步骤的变换:局部坐标–世界坐标–观察坐标–裁剪坐标–屏幕坐标,这里因为实例是2D的,暂时还不需要关心这些)
现在需要记得的是,OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。所有在所谓的标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上(在这个范围以外的坐标都不会显示)。
2D情况下,既不考虑z轴,则一般来说顶点坐标系如下所示:
通过顶点着色器和片段着色器,我们可以指定要绘制的物体形状大小以及颜色,但是如果我们要做类似将一张图片绘制上去,该如何做呢?
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作为中间的适配器。
//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
原创不易,如果你觉得好,随手点赞,也是对笔者的肯定~