一.前言
- 在学习音视频的过程中,实现视频渲染是非常常见的,而渲染的方式也挺多,可以使用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 );
}
@Override
public void surfaceChanged(@NonNull SurfaceHolder surfaceHolder, int i, int i1, int i2) {
}
@Override
public void surfaceCreated(@NonNull SurfaceHolder surfaceHolder) {
new Thread( this ).start();
}
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder surfaceHolder) {
}
@Override
public void onDrawFrame(GL10 gl10) {
}
@Override
public void onSurfaceChanged(GL10 gl10, int i, int i1) {
}
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
}
public native void PlayYuv(String url,Object surface);
@Override
public 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.PlayView android:layout_width="match_parent" android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
2.2.C++层代码
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视频播放器的基础,这块的知识很有必要熟练掌握。