涉及硬件的音视频能力,比如采集、渲染、硬件编码、硬件解码,通常是与客户端操作系统强相关的,就算是跨平台的多媒体框架也必须使用平台原生语言的模块来支持这些功能
本系列文章将详细讲述移动端音视频的采集、渲染、硬件编码、硬件解码这些涉及硬件的能力该如何实现,其中的示例代码,在我们的 Github 仓库 MediaPlayground 中都能找到,与文章结合着一起食用,味道更棒哦
本文为该系列文章的第 4 篇,将详细讲述在 iOS 平台下如何使用 OpenGL 实现视频画面的渲染,对应了我们 MediaPlayground 项目中 SceneVCVideoRenderOpenGLES 涉及到的内容
往期精彩内容,可参考
前言
之前讲了视频采集和硬件编码,得到的数据都是未编码的原始图像数据,那要如何将这些数据显示到手机屏幕上呢,就要用到渲染引擎了
iOS 中的渲染引擎有 2 种,分别是 OpenGL 和 Metal。有经验的小伙伴应该知道,Apple 从 iOS 12 开始就把 OpenGL 标记为废弃了,转而推荐使用 Metal,为的是在 iOS 设备上能有更好的图像性能表现,但 OpenGL 毕竟是跨平台的渲染引擎,在音视频这种对跨平台需求比较强烈的业务场景中,还是有用武之地的,不能一棒子打死,Android 和华为的纯血鸿蒙都还在使用 OpenGL
这么多年在各大厂的经验,笔者也亲眼见过许多改不动的老项目还在用 OpenGL,不是改不了 Metal,而是风险太大,收益太小,投入产出比不足以推动开发人员在 iOS 平台上把 OpenGL 的内容用 Metal 全部重新实现一遍,因此 OpenGL 还是值得拿出来讲一讲的
简单介绍OpenGL
OpenGL 是什么,全称 Open Graphics Library,简单理解就是一套用来操作图像的接口规范,最早的 1.0 版本是在 1992 年 6 月 30 号发布的,内部的具体逻辑是由各个操作系统平台的显卡生产商自行实现的,例如 Apple 生态中的 OpenGL 就是由 Apple 进行实现的
iOS 中使用的 OpenGL,严格来说应该叫 OpenGL ES,也就是 OpenGL for Embedded Systems,是针对手机、游戏主机等嵌入式设备进行设计的,算是 OpenGL 的子集
介绍就先讲这么多,更多 OpenGL 的历史背景,可以自行 google
学习思路
正常的逻辑思维中,要上手 OpenGL,首先要学习一大堆的基础知识,比如 OpenGL 中的各种概念(纹理、着色器、帧缓存、顶点坐标、纹理坐标),再上手编程
笔者也走过这条路,发现这条路异常的难走,因为最开始要积累的基础知识太多了,等学完并深入理解,可能几天就过去了,等到要串起来使用的时候,发现有些知识都忘记了,还得回头再看概念,效率比较低。因此本文会换个角度,先从宏观视角告诉大家怎样把 OpenGL 中的各种概念串联起来,实现图像的渲染,再去深挖各个环节中的细节
在阅读的过程中,大家可能会针对某个环节的某个操作产生各种疑问,没关系,本篇文章先教大家怎么做,下篇文章会根据本文中的技术点进行更详细的剖析,来解释为什么要这么做
宏观流程
在开始之前,要知道一个概念,就是执行图像渲染的模块,我们称之为渲染管线,这在不同的渲染引擎中都是类似的
如果把 OpenGL 的渲染管线想象成一个黑盒,支持输入一份图像数据 A,输出一份图像数据 B。图像渲染的过程,其实就是图像 A 经过 OpenGL 渲染管线之后变成了图像 B 的过程
我们把图像渲染,想象成手工绘画,必需的物理元素有画板、画纸、画笔、颜料,还有个最关键的元素,就是我们人脑中的创意
简单来说绘画的流程,就是我们把人脑中的创意用画笔 + 颜料等工具绘制到画纸的过程。那如果把图像渲染跟绘画进行类比,就会有如下的对应关系
- 人脑中的创意 => 作为输入的图像 A
- 画纸 => 作为输出的图像 B
- 画板 => OpenGL 的帧缓存
- 画纸上能画的区域 => OpenGL 中的顶点坐标
- 人脑中的图像要画上画纸的区域(可能选一部分画,也可能全部都画) => OpenGL 中的纹理坐标
- 人脑中对于图像的色彩分配 => OpenGL 中的着色器程序
这里引入了顶点坐标、纹理坐标、着色器程序、帧缓存的概念,都是图像渲染过程的必需要素,需要记住它们在绘画例子中的对应关系,图像渲染的数据流程,就变成了这样。理解了宏观流程,我们就可以开始看微观细节了
微观细节
下面讲用一个例子来展开讲 OpenGL 渲染的细节
场景:将视频采集之后得到的 NV12 图像数据渲染在屏幕上
注意:因为原始图像数据的格式是 NV12,根据 NV12 格式的特点,数据会分为 Y 和 UV 两个平面,因此要有 2 个输入图像才能正常进行渲染,宏观流程会变成这个样子
系统框架
要在 iOS 上调用 OpenGL 的接口,需要引入头文件
#import <OpenGLES/ES2/gl.h>
#import <OpenGLES/EAGL.h>
必要资源
上下文
OpenGL 内部的资源,跟 OpenGL 上下文是一一绑定的,每个上下文都有自己独立的一套资源,这是 OpenGL 定义的规则,体现的是状态机的思想,遵守就好。记住:任何与 OpenGL 相关的接口,必须在当前线程绑定了对应上下文的条件下执行。比如上下文 C 中有顶点坐标 C1 和纹理坐标 C2,在操作 C1 和 C2 时,就必须先在当前线程绑定上下文 C,让 OpenGL 知道我们在操作上下文 C 中的资源
创建上下文的代码
self.gl_context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
绑定上下文的代码
[EAGLContext setCurrentContext:self.gl_context];
采样纹理
在渲染流程中,渲染管线需要有输入,也就是宏观流程里的图像 A1 和 A2,对应了绘画流程中人脑里的创意,但在 OpenGL 的世界观里,图像又叫做“纹理”,后续都会用“纹理”来指代图像,纹理也称为 texture
现在有了原始图像数据,那么原始图像数据要怎么被 OpenGL 所识别呢,就需要把原始图像数据转化为 OpenGL 认识的纹理,在 iOS 中有 2 种方式,使用哪种取决于原始图像数据的来源
- 如果原始图像数据被包装在 CVPixelBuffer 中,也就是我们例子中的场景,那么这块纹理是由系统创建并管理生命周期的,我们只需要拿到它就好。为了能拿到它,我们需要创建 CVOpenGLESTextureCache。注意:这个 cache 的生命周期需要我们自行管理
CVOpenGLESTextureCacheCreate(kCFAllocatorDefault, NULL, self.gl_context, NULL, &texture_cache_);
2. 如果原始图像数据本身已经在内存中(有可能是读取本地图片得到的,也有可能是通过软件解码得到的),就需要自行创建纹理并维护其生命周期(本文中的例子用不到,建议放在一起对比着看,加深理解)
// 激活某个纹理单元
glActiveTexture(GL_TEXTURE0);
// 创建纹理
glGenTextures(1, &texture_id_);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, texture_id_);
// 设置纹理采样参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
渲染目标
渲染目标就是渲染管线的输出,也就是宏观流程里的图像 B,对应了绘画场景中的画纸,在 OpenGL 看来,也是一个纹理
在 OpenGL 的定义中,一个纹理,必须附着在一个帧缓存上,才能成为渲染目标,这里的帧缓存就对应了绘画场景下的画板,用于承载画纸,帧缓存也称为 frame buffer
根据渲染目标的不同用途,frame buffer 承载纹理的方式也不同,常用的有 2 种
- 想要渲染的结果能在屏幕上展示,也就是我们例子中的场景,渲染目标的类型就不再是普通纹理那样的 GL_TEXTURE_2D 了,而是 GL_RENDERBUFFER。在 iOS 中需要自定义 UIView,修改 layerClass 为 CAEAGLLayer,然后创建 frame buffer 和 render buffer,将 frame buffer、render buffer、layer 三者进行绑定
+ (Class)layerClass {
return [CAEAGLLayer class];
}
- (void)createRenderResource {
CAEAGLLayer *eagl_layer = (CAEAGLLayer *)self.layer;
eagl_layer.opaque = YES;
eagl_layer.contentsScale = [UIScreen mainScreen].scale;
eagl_layer.drawableProperties = @{kEAGLDrawablePropertyRetainedBacking:@YES};
[EAGLContext setCurrentContext:self.context];
glGenRenderbuffers(1, &render_buffer_id_);
glBindRenderbuffer(GL_RENDERBUFFER, render_buffer_id_);
[self.context renderbufferStorage:GL_RENDERBUFFER fromDrawable:eagl_layer];
glGenFramebuffers(1, &frame_buffer_id_);
glBindFramebuffer(GL_FRAMEBUFFER, frame_buffer_id_);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, render_buffer_id_);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &backing_width_);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &backing_height_);
}
2. 如果渲染目标并不需要在屏幕上展示,只需要创建 frame buffer 和 texture,将两者进行绑定即可(本文中的例子用不到,建议放在一起对比着看,加深理解)
glActiveTexture(GL_TEXTURE0);
glGenTextures(1, &texture_id_);
glBindTexture(GL_TEXTURE_2D, texture_id_);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
glGenFramebuffers(1, &frame_buffer_id_);
glBindFramebuffer(GL_FRAMEBUFFER, frame_buffer_id_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture_id_, 0);
着色器程序
着色器程序决定了渲染管线要如何将颜色填充到渲染目标中,对应了绘画场景中人脑对于图像的色彩分配
在创建着色器程序之前,需要准备 2 段代码,分别是顶点着色器和片段着色器
- 顶点着色器负责将外部指定的顶点坐标运用到渲染目标上,同时将外部指定的纹理坐标传递给纹理着色器
- 纹理着色器负责了单个像素点的颜色采样逻辑(在本案例中,就是将 NV12 中的 Y、UV 分量转化为 RGB 三原色的形式)
将顶点着色器和片段着色器与着色器程序进行关联,着色器程序就准备好了
// 创建顶点着色器
LoadShader(vertex_shader, GL_VERTEX_SHADER, &vertex_shader_id_);
// 创建片段着色器
LoadShader(fragment_shader, GL_FRAGMENT_SHADER, &fragment_shader_id_);
// 创建着色器程序
shader_program_id_ = glCreateProgram();
// 关联顶点着色器和片段着色器
glAttachShader(shader_program_id_, vertex_shader_id_);
glAttachShader(shader_program_id_, fragment_shader_id_);
// 进行链接
glLinkProgram(shader_program_id_);
// linked 代表着色器程序的链接结果,如果为 0 说明顶点或片段着色器存在问题
GLint linked;
glGetProgramiv(shader_program_id_, GL_LINK_STATUS, &linked);
单次渲染流程
前期准备工作都做完后,就可以让渲染管线真正跑起来了,分为以下几个步骤
第 1 步:将输入的图像 A1 和 A2 的数据,关联到用于采样的纹理
本例中,由于原始图像数据的格式是 NV12,因此需要准备 2 个用于采样的纹理,宏观流程图中的 A1 和 A2 分别对应 Y 平面和 UV 平面。根据图像来源的不同,分为 2 种方式,跟之前准备采样纹理时一样
- 如果原始图像数据被包装在 CVPixelBuffer 中,也就是我们例子中的场景,那么从 CVPixelBuffer 中就能拿到纹理
glActiveTexture(GL_TEXTURE0);
// NV12 的 Y 分量
CVOpenGLESTextureRef cv_texture = nullptr;
CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, texture_cache_, pixel_buffer, NULL, GL_TEXTURE_2D, GL_LUMINANCE, pixel_width, pixel_height, GL_LUMINANCE, GL_UNSIGNED_BYTE, 0, &cv_texture);
texture_id_ = CVOpenGLESTextureGetName(cv_texture);
glBindTexture(GL_TEXTURE_2D, texture_id_);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
CFRelease(cv_texture);
glActiveTexture(GL_TEXTURE0);
// NV12 的 UV 分量
CVOpenGLESTextureRef cv_texture = nullptr;
CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, texture_cache_, pixel_buffer, NULL, GL_TEXTURE_2D, GL_LUMINANCE_ALPHA, pixel_width / 2, pixel_height / 2, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, 1, &cv_texture);
texture_id_ = CVOpenGLESTextureGetName(cv_texture);
glBindTexture(GL_TEXTURE_2D, texture_id_);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
CFRelease(cv_texture);
2. 如果原始图像数据本身已经在内存中,就需要将内存中的数据传递到之前创建好的纹理中(本文中的例子用不到,建议放在一起对比着看,加深理解)
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture_id_);
// 双平面 YUV 的 Y 分量
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, pixel_width, pixel_height, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, pixel_data);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture_id_);
// 双平面 YUV 的 UV 分量
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA, pixel_width / 2, pixel_height / 2, 0, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, pixel_data);
第 2 步:准备渲染目标
不管渲染后的结果需不需要上屏显示,只需要操作帧缓存 frame buffer 即可
// 缓存旧的 frame buffer id
GLint frame_buffer_id_old = 0;
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &frame_buffer_id_old);
// 获取到本次渲染的 frame buffer id 并绑定
GLint frame_buffer_id = [self.display_view getFrameBufferId];
glBindFramebuffer(GL_FRAMEBUFFER, frame_buffer_id);
// 设置渲染目标的尺寸大小
glViewport(0, 0, [self.display_view getBackingWidth], [self.display_view getBackingHeight]);
// 设置渲染目标默认的颜色
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
第 3 步:启动着色器程序
glUseProgram(shader_program_id_);
第 4 步:将采样纹理绑定到片段着色器中的 uniform 变量
也就是告诉片段着色器要用哪个纹理进行采样。代码中 name 就是片段着色器中类型为 sampler2D 的 uniform 变量的名称。对多个纹理进行同时采样时,要激活多个纹理单元然后分别绑定,texture_layer 的取值范围是 0 - 31
glActiveTexture(GL_TEXTURE0 + texture_layer);
glBindTexture(GL_TEXTURE_2D, texture_id);
int location = glGetUniformLocation(shader_program_id_, name);
glUniform1i(location, texture_layer);
第 5 步:将顶点坐标传递给顶点着色器
// 通过着色器程序,拿到顶点坐标属性的 index
vertex_coordinates_attribute_index_ = glGetAttribLocation(shader_program_id_, "vertex_coordinates");
// 传递数据
GLuint vertex_coordinates_buffer_id = 0;
glGenBuffers(1, &vertex_coordinates_buffer_id);
glBindBuffer(GL_ARRAY_BUFFER, vertex_coordinates_buffer_id);
glBufferData(GL_ARRAY_BUFFER, 4*2*sizeof(float_t), OpenGLESDefaultVertexCoordinates, GL_STATIC_DRAW);
glVertexAttribPointer(vertex_coordinates_attribute_index, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), NULL);
glEnableVertexAttribArray(vertex_coordinates_attribute_index);
第 6 步:将纹理坐标传递给顶点着色器,然后会由顶点着色器透传给片段着色器
// 渲染结果要在屏幕上展示,则对纹理坐标进行垂直方向的反转
float texture_coordiantes[8];
memcpy(texture_coordiantes, OpenGLESDefaultTextureCoordinates, sizeof(float)*8);
texture_coordiantes[1] = OpenGLESDefaultTextureCoordinates[3];
texture_coordiantes[3] = OpenGLESDefaultTextureCoordinates[1];
texture_coordiantes[5] = OpenGLESDefaultTextureCoordinates[7];
texture_coordiantes[7] = OpenGLESDefaultTextureCoordinates[5];
// 通过着色器程序,拿到纹理坐标属性的 index
texture_coordinates_attribute_index_ = glGetAttribLocation(shader_program_id_, "texture_coordinates");
// 传递数据
GLuint texture_coordinates_buffer_id = 0;
glGenBuffers(1, &texture_coordinates_buffer_id);
glBindBuffer(GL_ARRAY_BUFFER, texture_coordinates_buffer_id);
glBufferData(GL_ARRAY_BUFFER, 4*2*sizeof(float_t), texture_coordiantes, GL_STATIC_DRAW);
glVertexAttribPointer(texture_coordinates_attribute_index_, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), NULL);
glEnableVertexAttribArray(texture_coordinates_attribute_index_);
第 7 步:调用绘制接口
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
第 8 步:停止着色器程序
glUseProgram(0);
第 9 步:清理状态
// 释放临时 buffer
glDeleteBuffers(1, &vertex_coordinates_buffer_id);
glDeleteBuffers(1, &texture_coordinates_buffer_id);
// 绑定之前的 frame buffer,恢复到本次渲染之前的状态
glBindFramebuffer(GL_FRAMEBUFFER, frame_buffer_id_old);
第 10 步:将渲染好的内容呈现到屏幕上
[self.gl_context presentRenderbuffer:GL_RENDERBUFFER];
到此为止,单次的渲染流程就走完了,在本例中,对应着视频采集单次回调的图像数据从开始渲染直到在屏幕上展示的过程。仔细想想,跟手工绘画是不是有异曲同工之妙,只不过是执行动作的主体从人变为了计算机
释放资源
OpenGL 没有自动回收资源的机制,因此资源一定要释放的干净彻底,否则一旦出现内存泄露,会出现各种难以排查的奇怪问题
采样纹理的释放,跟之前创建时一样,取决于原始图像数据的来源
- 如果原始图像数据被包装在 CVPixelBuffer 中,也就是我们例子中的场景,那么不需要删除单个纹理,只释放 CVOpenGLESTextureCache 即可
CFRelease(texture_cache_);
2. 如果原始图像数据本身已经在内存中,就需要手动删除创建的纹理(本文中的例子用不到,建议放在一起对比着看,加深理解)
glDeleteTextures(1, &texture_id_);
释放渲染目标时,先删除帧缓存
glDeleteFramebuffers(1, &frame_buffer_id_);
再根据用途,执行不同的资源释放
- 想要渲染的结果能在屏幕上展示,也就是我们例子中的场景,需要删除与 frame buffer 绑定的 render buffer
glDeleteRenderbuffers(1, &render_buffer_id_);
2. 如果渲染目标并不需要在屏幕上展示,需要删除与 frame buffer 绑定的 texture(本文中的例子用不到,建议放在一起对比着看,加深理解)
glDeleteTextures(1, &texture_id_);
释放着色器程序相关的资源,先将着色器实例与程序解除绑定,再挨个删除
glDetachShader(shader_program_id_, vertex_shader_id_);
glDetachShader(shader_program_id_, fragment_shader_id_);
glDeleteProgram(shader_program_id_);
glDeleteShader(vertex_shader_id_);
glDeleteShader(fragment_shader_id_);
懵逼了?别着急
如果之前略微接触过 OpenGL 的小伙伴,看到这里应该已经胸有成竹了,但部分没有 OpenGL 基础的小伙伴可能还是有些懵逼,因为上面讲的内容告诉了你想要实现图像渲染应该怎么做,但没有告诉你为什么这么做。别着急,在下篇文章中,我们会结合代码挨个解释上述每个步骤中需要注意的点,把 2 篇文章放在一起食用,效果更佳
本文的代码,对应了我们 MediaPlayground 项目中 SceneVCVideoRenderOpenGLES 涉及到的内容,有兴趣的小伙伴可以下载下来跑一跑,探索一下,更能加深对于 OpenGL 的理解
写在最后
以上就是本文的所有内容了,详细介绍了在 iOS 平台下如何使用 OpenGL 实现视频画面的渲染。下篇文章我们将详细解释本文中的代码为什么要这么写,以及与 OpenGL 相关的基础原理
本文为音视频基础能力系列文章的第 4 篇
往期精彩内容,可参考
后续精彩内容,敬请期待
音视频基础能力系列文章的示例代码,在我们的 Github 仓库 MediaPlayground 中都能找到,与文章结合着一起食用,味道更棒哦
如果您觉得以上内容对您有所帮助的话,欢迎关注我们运营的公众号声知视界,会定期的推送音视频技术、移动端技术为主轴的科普类、基础知识类、行业资讯类等文章