Android音视频探索之旅 | C++层使用OpenGL ES实现视频渲染
一.前言
- 在学习音视频的过程中,实现视频渲染是非常常见的,而渲染的方式也挺多,可以使用Java层的OpenGL ES进行图形渲染,也可以使用Ffmpeg来显示,还有就是通过C++层的OpenGL ES来进行渲染。
- OpenGL ES是OpenGL三维图形API的子集,本文针对通过C++层实现OpenGL ES来进行渲染视频做一下记录。
- 整体效果:
二.使用OpenGL ES实现视频渲染功能
- 若想在NDK代码中使用OpenGL,还得借助EGL这座桥梁才行。对于Android系统而言,EGL(Enterprise Generation Language,企业生成语言)是OpenGL ES与原生窗口之间的接口层。
- 使用OpenGL ES实现视频渲染功能的大体步骤,可以划分为4个步骤:
- 初始化EGL & 让EGL接管原生窗口ANativeWindow
- 初始化OpenGL ES
- 该部分是跟着色器和纹理有关的
- 通过OpenGL ES渲染视频画面
- 释放EGL资源
1.准备工作
- 创建支持native的Android项目,以及相关的so文件和yuv文件(这部分copy步骤三提供的项目中的即可)。
- 对cmake的知识需要有基础的掌握,这部分知识可以查看Android音视频探索之旅 | CMake基础语法 && 创建支持Ffmpeg的Android项目。
2.代码环节
2.1.自定义一个GLSurfaceView实现类
package com.jack.ffmpeg_simple02;import android.content.Context;
import android.opengl.GLSurfaceView;
import android.util.AttributeSet;
import android.view.SurfaceHolder;
import androidx.annotation.NonNull;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;/*** @创建者 Jack* @创建时间 2025-07-12 12:40* @描述*/
public class PlayView extends GLSurfaceView implements SurfaceHolder.Callback, GLSurfaceView.Renderer,Runnable{public PlayView(Context context, AttributeSet attrs) {super(context, attrs);//适配Android8.0及以上 无法正常渲染视频的问题setRenderer( this );}@Overridepublic void surfaceChanged(@NonNull SurfaceHolder surfaceHolder, int i, int i1, int i2) {}@Overridepublic void surfaceCreated(@NonNull SurfaceHolder surfaceHolder) {new Thread( this ).start();}@Overridepublic void surfaceDestroyed(@NonNull SurfaceHolder surfaceHolder) {}@Overridepublic void onDrawFrame(GL10 gl10) {}@Overridepublic void onSurfaceChanged(GL10 gl10, int i, int i1) {}@Overridepublic void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {}public native void PlayYuv(String url,Object surface);@Overridepublic void run() {//注意:需要手动开启存储权限PlayYuv("/sdcard/out.yuv",getHolder().getSurface());}
}
- 并在MainActivity中引入使用
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:tools="http://schemas.android.com/tools"android:layout_width="match_parent"android:layout_height="match_parent"tools:context=".MainActivity"><com.jack.ffmpeg_simple02.PlayViewandroid:layout_width="match_parent"android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
2.2.C++层代码
#include <jni.h>
#include <string>
#include <android/log.h>
#include <android/native_window_jni.h>
#include <EGL/egl.h>
#include <GLES2/gl2.h>
#include <string.h>#define LOGD(...) __android_log_print(ANDROID_LOG_WARN,"Test OpenGL ES ",__VA_ARGS__)//着色器之间不能直接通信,每个着色器都是独立的小程序,它们唯一的交流信息就是输入和输出参数
//着色器的小程序采用GLSL语言(OpenGL Shader Language)编写//常用的限定符主要有attribute、varying、in、out、uniform五个,分别说明如下。
// attribute:表示该变量是输入参数。GLSL 2.0使用。
// varying:表示该变量是输出参数。GLSL 2.0使用。
// in:表示该变量是输入参数。GLSL 3.0使用。
// out:表示该变量是输出参数。GLSL 3.0使用。
// uniform:表示该变量是全局参数。// **顶点着色器** glsl 确定位置
//以下场景,顶点着色器 的模板几乎是固定的
//✅ 在屏幕上渲染一张2D纹理(如图片/视频)。
//✅ 处理坐标系差异(如YUV数据)。
//✅ 简单的全屏绘制。
#define GET_STR(x) #x
static const char *vertexShader = GET_STR(attribute vec4 aPosition; //顶点坐标attribute vec2 aTexCoord; //材质顶点坐标varying vec2 vTexCoord; //输出的材质坐标(处理后的纹理坐标(传递给片元着色器))//声明完数据变量之后,即可编写形如void main() { /*里面是具体的实现代码*/ }的小程序代码。void main() {// 翻转Y坐标 翻转的原因:确保图像方向正确。vTexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);//简单投影:代码中顶点坐标已经是裁剪空间坐标(范围[-1,1]),直接赋值即可全屏渲染。例如:顶点坐标(1.0, -1.0, 0.0)对应屏幕右下角。gl_Position = aPosition;//给内置的位置变量赋值}
);//片元着色器,软解码和部分x86硬解码
//这段片元着色器代码是专门为YUV420P格式视频数据转RGB渲染设计的(细节部分暂时没有必要关注)
static const char *fragYUV420P = GET_STR(precision mediump float; //中等精度浮点数 作用:平衡性能和精度,适合移动端GPU(OpenGL ES要求必须声明精度)。varying vec2 vTexCoord; //顶点着色器传递的坐标//YUV420P数据存储为三个独立平面(Y全分辨率,U/V半分辨率),需分别采样。uniform sampler2D yTexture; //输入的材质(不透明灰度,单像素)uniform sampler2D uTexture;uniform sampler2D vTexture;void main() {vec3 yuv;vec3 rgb;yuv.r = texture2D(yTexture, vTexCoord).r;yuv.g = texture2D(uTexture, vTexCoord).r - 0.5;yuv.b = texture2D(vTexture, vTexCoord).r - 0.5;rgb = mat3(1.0, 1.0, 1.0,0.0, -0.39465, 2.03211,1.13983, -0.58060, 0.0) * yuv;//输出像素颜色gl_FragColor = vec4(rgb, 1.0);}
);GLint InitShader(const char *code, GLint type) {//8.创建指定类型的着色器并返回着色器的编号。输入参数填着色器的类型,其中 GL_VERTEX_SHADER 表示顶点着色器,GL_FRAGMENT_SHADER 表示片段(元)着色器。GLint sh = glCreateShader(type);if (sh == 0) {LOGD("glCreateShader %d failed!", type);return 0;}//9.指定着色器的程序内容。 第一个参数填着色器编号,第二个参数填1,表示1个着色器, 第三个参数填着色器的代码字符串glShaderSource(sh,1, //shader数量&code, //shader代码0); //代码长度//10.编译着色器的程序代码。输入参数填着色器编号glCompileShader(sh);//11.获取编译情况GLint status;glGetShaderiv(sh, GL_COMPILE_STATUS, &status);if (status == 0) {LOGD("glCompileShader failed!");return 0;}LOGD("glCompileShader success!");return sh;
}extern "C"
JNIEXPORT void JNICALL
Java_com_jack_ffmpeg_1simple02_PlayView_PlayYuv(JNIEnv *env, jobject thiz, jstring url_,jobject surface) {const char *url = env->GetStringUTFChars(url_, 0);LOGD("open url is %s", url);FILE *fp = fopen(url, "rb");if (!fp) {LOGD("open file %s failed!", url);return;}//1-7:可以看成 ***** 步骤一,初始化EGL & 让EGL接管原生窗口ANativeWindow *****//尽管EGL本身属于接口层,但EGL的表面对象不是凭空产生的,而是从原生窗口接管而来的。//要引入ANativeWindow,从原生窗口接管表面对象,然后才能创建用于OpenGL ES的EGL环境//1.获取原始窗口---从Surface获取原生窗口ANativeWindow *nwin = ANativeWindow_fromSurface(env, surface);//2.获取EGL显示器EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);if (display == EGL_NO_DISPLAY) {LOGD("eglGetDisplay failed!");return;}if (EGL_TRUE != eglInitialize(display, 0, 0)) {LOGD("eglInitialize failed!");return;}//3.输出配置// EGL配置EGLConfig config;// 配置数量EGLint configNum;//配置规格,涉及RGB颜色空间EGLint configSpec[] = {EGL_RED_SIZE, 8,EGL_GREEN_SIZE, 8,EGL_BLUE_SIZE, 8,EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_NONE};//4.eglChooseConfig:选择配置。给EGL显示器选择最佳配置。第一个参数为EGLDisplay类型的显示器变量,第二个参数为指定了RGB颜色空间的配置规格,第三个参数为EGLConfig类型的配置变量。if (EGL_TRUE != eglChooseConfig(display, configSpec, &config, 1, &configNum)) {LOGD("eglChooseConfig failed!");return;}//5.创建EGL表面。这里EGL接管了原生窗口的表面对象EGLSurface winsurface = eglCreateWindowSurface(display, config, nwin, 0);if (winsurface == EGL_NO_SURFACE) {LOGD("eglCreateWindowSurface failed!");return;}const EGLint ctxAttr[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};//6.结合 EGL显示器 与 EGL配置创建EGL 实例EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, ctxAttr);if (context == EGL_NO_CONTEXT) {LOGD("eglCreateContext failed!");return;}//7.创建EGL环境,之后即可执行OpenGL的相关操作。第一个参数为EGLDisplay类型的显示器变量,第二个参数为绘制需要的EGL表面变量,第三个参数为读取需要的EGL表面变量if (EGL_TRUE != eglMakeCurrent(display, winsurface, winsurface, context)) {LOGD("eglMakeCurrent failed!");return;}LOGD("EGL Init Success!");//8-24 可以看成 ***** 步骤二,初始化OpenGL ES *****//该部分是跟着色器纹理有关的,这三个部分可以划分为3个小一点的步骤//8-16,划分到步骤01中. ***** 分别依据对应小程序,初始化顶点着色器和片段着色器,并获取着色器链接后的小程序编号. *****//17-19,划分到步骤02中,***** 根据小程序编号设置 顶点坐标 和 材质坐标 *****//20-24,划分到步骤03中,***** 分别创建Y、U、V三个分量的纹理,并分别设置三个纹理分量的规格与材质 *****//8-11,顶点和片元shader初始化//顶点shader初始化GLint vsh = InitShader(vertexShader, GL_VERTEX_SHADER);//片元yuv420 shader初始化GLint fsh = InitShader(fragYUV420P, GL_FRAGMENT_SHADER);//12.创建小程序,并返回小程序的编号GLint program = glCreateProgram();if (program == 0) {LOGD("glCreateProgram failed!");return;}//13.将着色器的编译结果添加至小程序。第一个参数填小程序编号,第二个参数填着色器编号glAttachShader(program, vsh);glAttachShader(program, fsh);//14.链接着色器的小程序。输入参数填小程序编号glLinkProgram(program);GLint status = 0;//15.检查着色器链接是否成功。 第一个参数填小程序编号, 第二个参数填 GL_LINK_STATUS ,第三个参数填待返回的状态变量。 状态值为GL_TRUE表示成功,其他表示失败。glGetProgramiv(program, GL_LINK_STATUS, &status);if (status != GL_TRUE) {LOGD("glLinkProgram failed!");return;}//16.使用小程序。输入参数填小程序编号glUseProgram(program);LOGD("glLinkProgram success!");//加入三维顶点数据 两个三角形组成正方形static float vers[] = {1.0f, -1.0f, 0.0f,-1.0f, -1.0f, 0.0f,1.0f, 1.0f, 0.0f,-1.0f, 1.0f, 0.0f,};//17.从小程序获取属性变量的位置索引。第一个参数填小程序编号,第二个参数填属性变量的名称。输出参数为属性变量的位置索引GLuint apos = (GLuint) glGetAttribLocation(program, "aPosition");//18.启用顶点属性数组。输入参数为属性变量的位置索引glEnableVertexAttribArray(apos);//19.指定顶点属性数组的位置索引及其数据格式。第一个参数填属性变量的位置索引;第二个参数填属性的长度,对于三维空间填3,因为三维空间有x、y、z三个方向,对于二维空间填2,因为二维空间只有x、y两个方向glVertexAttribPointer(apos, 3, GL_FLOAT, GL_FALSE, 12, vers);// 上面的三个函数要分别调用两轮,其中第一轮的 glGetAttribLocation → glEnableVertexAttribArray → glVertexAttribPointer 设置顶点坐标,// 第二轮的glGetAttribLocation→glEnableVertexAttribArray→glVertexAttribPointer设置材质坐标。// 之所以设置完 顶点坐标 还要设置 材质坐标 ,是因为后面要往顶点组成的轮廓粘贴图像纹理,才能呈现最终的空间景象。这里的图像纹理就来自视频帧的YUV图像。//故:下方的就不做过多注释//加入材质坐标数据static float txts[] = {1.0f, 0.0f, //右下0.0f, 0.0f,1.0f, 1.0f,0.0, 1.0};GLuint atex = (GLuint) glGetAttribLocation(program, "aTexCoord");glEnableVertexAttribArray(atex);glVertexAttribPointer(atex, 2, GL_FLOAT, GL_FALSE, 8, txts);//注意:测试,写定的数据(要跟YUV中的宽高保持一致,否则画面显示会存在异常)int width = 424;int height = 240;//20.设置纹理层//glGetUniformLocation:获取纹理在小程序中的位置。第一个参数填小程序编号,第二个参数填纹理的名称。输出参数为纹理在小程序中的位置//glUniform1i:设置纹理层。第一个参数为纹理在小程序中的位置,第二个参数为纹理序号glUniform1i(glGetUniformLocation(program, "yTexture"), 0); //对于纹理第1层glUniform1i(glGetUniformLocation(program, "uTexture"), 1); //对于纹理第2层glUniform1i(glGetUniformLocation(program, "vTexture"), 2); //对于纹理第3层//21.创建opengl纹理GLuint texts[3] = {0};//glGenTextures:创建纹理数组。第一个参数为数组长度,填3表示有三个色彩分量;第二个参数填待创建的纹理数组。glGenTextures(3, texts);//22.绑定指定纹理(设置纹理属性)。第一个参数填GL_TEXTURE_2D,第二个参数填具体纹理,比如下标为0的纹理数组元素表示采用第一个分量的纹理。glBindTexture(GL_TEXTURE_2D, texts[0]);//23.设置纹理的过滤器glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);//24.设置纹理的规格与材质。glTexImage2D:对于Y分量来说,其宽高就是视频的宽高;对U分量和V分量来说,其宽高各为视频宽高的一半。最后一个参数填当前分量的缓存数据。// ***** 注意事项 *****//调用 glTexImage2D 函数时,最后一个参数非空表示直接渲染纹理。// 对于YUV空间来说,每个视频帧对三个分量各自调用 glUniform1i → glActiveTexture → glBindTexture → glTexParameteri → glTexImage2D ,// 最后只要调用一次 glDrawArrays 函数即可完成该帧图像的绘制操作。//调用 glTexImage2D 函数时,最后一个参数为空表示要分两步渲染纹理。// 第一步,对三个分量各自调用 glUniform1i→glBindTexture → glTexParameteri → glTexImage2D,表示先占个位;// 第二步,每个视频帧对三个分量各自调用 glActiveTexture → glBindTexture → glTexSubImage2D,表示替换当前分量的缓存数据,// 最后只要调用一次 glDrawArrays 函数即可完成该帧图像的绘制操作。// ***** 注意事项 *****glTexImage2D(GL_TEXTURE_2D,0, //细节基本 0默认GL_LUMINANCE, //gpu内部格式 亮度,灰度图width, height, //拉升到全屏0, //边框GL_LUMINANCE, //数据的像素格式 亮度,灰度图 要与上面一致GL_UNSIGNED_BYTE, //像素的数据类型NULL //纹理的数据);glBindTexture(GL_TEXTURE_2D, texts[1]);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,GL_LUMINANCE,width / 2, height / 2,0,GL_LUMINANCE,GL_UNSIGNED_BYTE,NULL);glBindTexture(GL_TEXTURE_2D, texts[2]);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,GL_LUMINANCE,width / 2, height / 2,0,GL_LUMINANCE,GL_UNSIGNED_BYTE,NULL);// 25-29 可以看成 ***** 步骤三 通过OpenGL ES渲染视频画面,轮询每个视频帧的时候,需要把视频帧的YUV数据写入对应的ES纹理 (for循环已经做了该部分) *****//轮询每个视频帧的时候,需要把视频帧的YUV数据写入对应的ES纹理//将file读取的内容,Y U V分别使用三个数组来临时存储 实际项目不要这么来写,需要通过一定的封装unsigned char *buf[3] = {0};buf[0] = new unsigned char[width * height];buf[1] = new unsigned char[width * height / 4];buf[2] = new unsigned char[width * height / 4];//模拟操作,实际项目不要这么写for (int i = 0; i < 10000; i++) {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);}//激活第1层纹理,绑定到创建的opengl纹理//25.激活纹理glActiveTexture(GL_TEXTURE0);//26.绑定指定纹理glBindTexture(GL_TEXTURE_2D, texts[0]);//27.替换纹理内容。最后一个参数填当前分量的缓存数据glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_LUMINANCE, GL_UNSIGNED_BYTE,buf[0]);//第2层纹理 同理glActiveTexture(GL_TEXTURE0 + 1);glBindTexture(GL_TEXTURE_2D, texts[1]);glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width / 2, height / 2, GL_LUMINANCE,GL_UNSIGNED_BYTE, buf[1]);//第3层纹理同理glActiveTexture(GL_TEXTURE0 + 2);glBindTexture(GL_TEXTURE_2D, texts[2]);glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width / 2, height / 2, GL_LUMINANCE,GL_UNSIGNED_BYTE, buf[2]);//28.采用顶点的坐标数组方式绘制图形glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);//29.将OpenGL的纹理缓存显示到屏幕上。第一个参数为EGLDisplay类型的显示器变量,第二个参数为EGLSurface类型的EGL表面变量。//把OpenGL ES的纹理缓存显示到屏幕上:在调用OpenGL ES的绘制函数之后,还要调用EGL的eglSwapBuffers函数,才能把OpenGL ES的纹理缓存显示到屏幕上eglSwapBuffers(display, winsurface);}// ***** 步骤四:释放EGL资源 *****//视频遍历结束,除释放FFmpeg相关的实例资源外,还要释放EGL的表面和实例资源,包括EGL用到的原生窗口也要释放。// 释放原生窗口// 销毁EGL表面// 销毁EGL实例env->ReleaseStringUTFChars(url_, url);
}
- 这部分的注释写的非常详细,当熟练了这块代码之后,可以对其进行封装、改善,效果更好。重点重复一下着色器相关的流程,大体划分为3步:
-
1.分别依据对应小程序,初始化顶点着色器和片段着色器,并获取着色器链接后的小程序编号
- 1.InitShader(返回着色器的编号):初始化顶点着色器和片段着色器;
- 2.创建小程序,并返回小程序的编号;
- 3.链接着色器的小程序。输入参数填小程序编号;
- 4.检查着色器链接是否成功;
- 5.使用小程序。输入参数填小程序编号;
-
2.根据小程序编号设置顶点坐标和材质坐标
- 1.从小程序获取属性变量的位置索引;
- 2.启用顶点属性数组;
- 3.指定顶点属性数组的位置索引及其数据格式;
-
3.分别创建Y、U、V三个分量的纹理,并分别设置三个纹理分量的规格与材质
- 1.设置纹理层;
- 2.创建纹理数组;
- 3.绑定指定纹理;
- 4.设置纹理的过滤器;
- 5.设置纹理的规格与材质;
- 6.替换纹理内容;
- 7.采用顶点的坐标数组方式绘制图形;
-
三.总结
- 项目代码可以在码云上面进行下载,6.0以上的设备需要手动开启动态权限,这部分代码没有写在项目里面。另外一个细节就是8.0及以上的设备需要做适配处理,详细的注释在代码中有提及。
- 通过OpenGL ES来渲染视频是实现Android视频播放器的基础,这块的知识很有必要熟练掌握。