聊聊 Android/iOS 的视频采集、渲染、编码
虽然 18 年就做过跨 Android/iOS 双端渲染的项目,但只是单纯地 2D 纹理渲染,不涉及视频流;
而后 19 年参与的图像识别项目,虽然涉及 Camera,但也仅限 Android 侧,而且也不涉及渲染。
近期有机会同时接触到 Android/iOS 直播项目,串联起图像采集/编码/渲染,终于深刻认识到双端图像处理的差异。
下面简单做下梳理。
图像采集
Android 图像采集
Android 的碎片化臭名昭著,在图像采集这块也不例外。
起初有一套 Camera,接口简单,输出格式一般是 NV21,5.0 开始被废弃;
Camera2 5.0 推出,虽然支持的特性更多,但是接口和流程也更复杂:
仅打开摄像头就涉及
CameraDevice
、CameraManager
、CameraCaptureSession
、CameraRequest
;读取数据还需要
ImageReader
,输出格式一般用I420
;
考虑到这个问题,官方又推出了 CameraX。
而在国内,OPPO、华为等第三方厂商还推出了自己的 Camera API。
iOS 图像采集
iOS 只有一套 AVFoundation
的接口,而且流程也相对更简单清晰:
AVCaptureDevice
对应 Android 的CameraDevice
;AVCaptureSession
对应 Android 的CameraCaptureSession
;AVCaptureOutput
大致类似 AndroidImageReader
的角色,用于处理数据回调。
OpenGL 渲染环境
Android 的 OpenGL 渲染环境
OpenGL ES 只定义了渲染 API,而没有定义窗口系统;所以要和原生窗口系统交互,渲染到屏幕,就需要创建 OpenGL 渲染环境,即 EGL。
Android 开放了完整的 EGL 接口:
需要全程繁琐地操控 EGL,才能创建 OpenGL ES 渲染环境,并最终渲染到屏幕:
- 初始化 EGL Display:
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
EGL14.eglInitialize(eglDisplay, version, 0, version, 1)
- 初始化 EGL 配置:
int[] attrs = {
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_NONE, 0,
EGL14.EGL_NONE
};
EGLConfig[] eglConfigs = new EGLConfig[1];
EGL14.eglChooseConfig(eglDisplay, attrs, 0, configs, 0, configs.length, 1, 0);
- 初始化 EGL 上下文:
int[] attrs = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE};
EGLContext eglContext = EGL14.eglCreateContext(eglDisplay, eglConfigs[0], sharedContext, attrs, 0);
EGL14.eglQueryContext(eglDisplay, eglContext, EGL14.EGL_CONTEXT_CLIENT_VERSION, values, 0);
- 通过
Surface
或SurfaceTexture
创建渲染到屏幕的EGLSurface
:
int[] attrs = {EGL14.EGL_NONE};
EGLSurface eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfigs[0], surface, attrs, 0);
- 创建离屏渲染环境:
int[] attrs = {EGL14.EGL_WIDTH, width, EGL14.EGL_HEIGHT, height, EGL14.EGL_NONE};
EGLSurface eglSurface = EGL14.eglCreatePbufferSurface(eglDisplay, eglConfigs[0], attrs, 0);
- 激活当前 EGL 环境以进行渲染:
EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext);
- 交换 Buffer,触发渲染到屏幕:
EGL14.eglSwapBuffers(eglDisplay, eglSurface);
也就是将 FrameBuffer 中的数据送去屏幕,逐行扫描刷新。
- 释放 EGL 资源:
EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT);
EGL14.eglDestroyContext(eglDisplay, eglContext);
EGL14.eglReleaseThread();
EGL14.eglTerminate(eglDisplay);
GLSurfaceView
内部有EglHelper
自动管理 EGL,所以不用上述操作。
iOS 的 OpenGL 渲染环境
iOS 虽然也有 EGL 环境:EAGL,但屏蔽了相关细节,无需手动操作 EGL,只需创建 CAEAGLLayer
:
_glContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
[EAGLContext setCurrentContext:_glContext];
_glLayer = [CAEAGLLayer layer];
_glLayer.frame = self.bounds;
_glLayer.opaque = NO;
_glLayer.drawableProperties = @{
kEAGLDrawablePropertyRetainedBacking:@NO,
kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8};
[self.layer addSublayer:_glLayer];
然后就可以 初始化 FrameBuffer 和 RenderBuffer 进行渲染。
类似地,Metal 需创建 CAMetalLayer
:
_device = MTLCreateSystemDefaultDevice();
_metalLayer = [CAMetalLayer layer];
_metalLayer.frame = self.bounds;
_metalLayer.opaque = NO;
_metalLayer.device = _device;
_metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm;
[self.layer addSublayer:_metalLayer];
渲染纹理
Android 渲染 RGB/YUV
不管 RGB 还是 YUV,都是通过 glTexImage2D
/glTexSubImage2D
加载:
private void loadTex2D(int format, int w, int h, Buffer data, int texId, boolean reuse) {
if (!reuse) {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId);
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, format, w, h, 0, format, GLES20.GL_UNSIGNED_BYTE, data);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
} else {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId);
GLES20.glTexSubImage2D(GLES20.GL_TEXTURE_2D, 0, 0, 0, w, h, format, GLES20.GL_UNSIGNED_BYTE, data);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
}
}
RGB 的 format
传 GLES20.GL_RGBA
,YUV 的 format
传 GLES20.GL_LUMINANCE
。
另外,Android Camera2 输出的 YUV_420_888
属于 I420(420p),Y、U、V 分别位于单独的通道,所以需要分三次加载:
loadTex2D(GLES20.GL_LUMINANCE, yStrides, h, buffer.position(yOffset), yTexId, sizeChanged);
loadTex2D(GLES20.GL_LUMINANCE, uStrides, h / 2, buffer.position(uOffset), uTexId, sizeChanged);
loadTex2D(GLES20.GL_LUMINANCE, vStrides, h / 2, buffer.position(vOffset), vTexId, sizeChanged);
Android 渲染 JPG/PNG
对于 JPG/PNG,Android 的 GLUtils
提供了直接加载 Bitmap
的能力:
GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + texture2dIndex);
if (!reuse) {
GLUtils.texImage2D(GL_TEXTURE_2D, 0, bitmap, 0);
} else {
GLUtils.texSubImage2D(GL_TEXTURE_2D, 0, 0, 0, bitmap);
}
bitmap.recycle();
从 android/opengl/util.cpp
源码看,最终仍然是调用的 glTexImage2D
/glTexSubImage2D
:
static jint util_texImage2D(JNIEnv *env, jclass clazz, jint target, jint level,
jint internalformat, jobject bitmapObj, jint type, jint border) {
graphics::Bitmap bitmap(env, bitmapObj);
AndroidBitmapInfo bitmapInfo = bitmap.getInfo();
//...
glTexImage2D(target, level, internalformat, bitmapInfo.width, bitmapInfo.height, border,
getPixelFormatFromInternalFormat(internalformat), type, bitmap.getPixels());
//...
}
Android 渲染压缩纹理
Android 支持的压缩纹理主要是 ETC1 和 ETC2,前者兼容性好,但不支持 alpha,具体可参考前文。
iOS 渲染 RGB/YUV
iOS 除了支持 OpenGL 标准的 glTexImage2D
/glTexSubImage2D
,还支持通过 CVPixelBuffer 创建纹理:
CVReturn status = CVOpenGLESTextureCacheCreate(NULL, NULL, context, NULL, &textureCache);
status = CVOpenGLESTextureCacheCreateTextureFromImage(NULL,
textureCache, pixelBuffer, NULL,
GL_TEXTURE_2D, // 离屏渲染用 GL_RENDERBUFFER
GL_RGBA,
frameWidth, frameHeight,
GL_BGRA,
L_UNSIGNED_BYTE, 0,
&texture);
对于 iOS 中的 YUV 格式:
带
BiPlanar
的为双平面 420sp(UV 共享通道),如kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
表示 NV12;不带
BiPlanar
的为 420p,如kCVPixelFormatType_420YpCbCr8Planar
表示 I420;CVPixelBufferGetPlaneCount()
可查看平面数目;
而硬件编解码场景,一般是 NV12,所以需要分两次加载,格式分别传 GL_LUMINANCE
和 GL_LUMINANCE_ALPHA
:
status = CVOpenGLESTextureCacheCreateTextureFromImage(NULL,
textureCache, pixelBuffer, NULL,
GL_TEXTURE_2D,
GL_LUMINANCE,
frameWidth, frameHeight,
GL_LUMINANCE,
GL_UNSIGNED_BYTE, 0,
&texture);
status = CVOpenGLESTextureCacheCreateTextureFromImage(NULL,
textureCache, pixelBuffer, NULL,
GL_TEXTURE_2D,
GL_LUMINANCE_ALPHA,
frameWidth / 2, frameHeight / 2,
GL_LUMINANCE_ALPHA,
GL_UNSIGNED_BYTE, 0,
&texture);
iOS 渲染 JPG/PNG
对于 JPG/PNG,iOS 同样提供了工具类 GLKTextureLoader
:
GLKTextureInfo* textureInfo = [GLKTextureLoader textureWithContentsOfFile:imagePath options:nil error:&error];
另外,Metal 还提供了 MTKTextureLoader
:
MTKTextureLoader* loader = [[MTKTextureLoader alloc]initWithDevice:device];
NSError* err = nil;
id<MTLTexture> texture = [loader newTextureWithContentsOfURL:[NSURL fileURLWithPath:imagePath] options:@{MTKTextureLoaderOptionSRGB: @NO} error:&err];
iOS 渲染压缩纹理
iOS 支持的压缩纹理主要是 PVRTC,具体可参考前文。
跨 CPU/GPU 数据共享
OpenGL 标准的 PBO 方案
某些场景,可能不得不将纹理数据从 GPU 下载到 CPU 处理,比如视频软编码;
由于是实时处理,如果直接频繁 glReadPixels()
,性能会很低,因为它是阻塞的。
因此 OpenGL ES 3.0 提供了 PBO。它是非阻塞的,且利用硬件 DMA,使 PBO 和 GPU 中的 Texture 能共享内存,避免 CPU/GPU 间的数据拷贝:
PBO 是双向的,UNPACK
用于上传(渲染数据到 GPU 纹理),PACK
用于下载(读取 GPU 纹理数据):
- 创建上传/下载的 PBO:
int bufferSize = imageWidth * imageHeight * 4; // RGBA
// 上传数据到纹理用 UNPACK
glGenBuffers(1, &uploadPbo);
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, uploadPbo);
glBufferData(GL_PIXEL_UNPACK_BUFFER, bufferSize, NULL, GL_STREAM_DRAW);
// 读取纹理数据用 PACK
glGenBuffers(1, &downloadPbo);
glBindBuffer(GL_PIXEL_PACK_BUFFER, downloadPbo);
glBufferData(GL_PIXEL_PACK_BUFFER, bufferSize, NULL, GL_STREAM_DRAW);
- 用 PBO 上传数据到纹理:
// 绑定 PBO
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, uploadPbo);
// 已绑定 PBO,最后一个参数表示偏移,传 0
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
// 拿到 PBO 内存指针
GLubyte *pboBuff = (GLubyte*)glMapBufferRange(GL_PIXEL_UNPACK_BUFFER, 0, dataSize, GL_MAP_WRITE_BIT);
// 将数据拷贝到 PBO
if (pboBuff) {
memcpy(pboBuff, data, dataSize);
glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER);
}
// 最后的参数 0,因为前面已通过 PBO 写数据
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, 0);
// 解绑 PBO
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
- 用 PBO 读取纹理数据:
// 绑定 PBO
glBindBuffer(GL_PIXEL_PACK_BUFFER, downloadPbo);
// 已绑定 PBO,最后一个参数表示偏移,传 0
glReadPixels(0, 0, *width, *height, GL_RGBA, GL_UNSIGNED_BYTE, 0);
// 拿到 PBO 内存指针
GLubyte *pboBuff = (GLubyte*)glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, dataSize, GL_MAP_WRITE_BIT);
// 从 PBO 拷贝数据
if (pboBuff) {
memcpy(*data, pboBuff, dataSize);
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
}
// 解绑 PBO
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
可以看到,关键点在于通过 glMapBufferRange()
做了内存映射,而内存映射依赖 DMA 硬件。
- 双 PBO 并行读写:
渲染领域常用双缓冲并行读写来提高效率,这里也可以用两个 PBO 进一步提高性能:
限于篇幅,代码就不展开了。
Android 的外部纹理
在视频实时采集并渲染的场景,如果拿到摄像头数据后,再从 CPU 加载为 GPU 纹理去渲染,就涉及 CPU/GPU 的频繁数据拷贝,存在严重的性能问题。
于是,外部纹理(GL_TEXTURE_EXTERNAL_OES
) 应运而生:
当 Camera 绑定外部纹理的 SurfaceTexture
,能直接将 BufferQueue
的数据渲染为纹理,并将数据标记为 GRALLOC_USAGE_HW_TEXTURE
,使得 OpenGL 能识别:
/* buffer will be used as an OpenGL ES texture */
GRALLOC_USAGE_HW_TEXTURE = 0x00000100U,
须注意外部纹理跟普通 2D 纹理的区别:
- 外部纹理的 shader 与普通 2D 纹理不同:
#extension GL_OES_EGL_image_external : require
precision highp float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vTextureCoord);
}
开头要声明 extension
,且 uniform
类型也不是 sampler2D
,而是 samplerExternalOES
;
- 外部纹理的配置也与普通 2D 纹理不同:
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId);
GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
第一个参数不是 GLES20.GL_TEXTURE_2D
,而是 GLES11Ext.GL_TEXTURE_EXTERNAL_OES
;
- 外部纹理不能执行普通 2D 纹理的操作
如果要处理摄像头输出的外部纹理,需要先将它绘制到 FrameBuffer。
至于外部纹理为啥能实现内存共享,我们后续文章会深入讨论。
iOS 的 IOSurface/TextureCache/CVPixelBuffer
IOSurface
IOSurface 是 Apple 生态中跨硬件(CPU/GPU)、跨系统(iOS/macOS)、跨进程、跨 API(OpenGL/Metal/CI/CG/CV)实现数据高效共享的关键:
An IOSurface is a kernel-managed chunk of texture memory that can be paged on or off the GPU automatically and shared across processes. When shared across processes no data is copied.
Share hardware-accelerated buffer data (framebuffers and textures) across multiple processes. Manage image memory more efficiently.
An IOSurface transcends APIs, architectures, address spaces, and processes.
至于 IOSurface 为啥能实现内存共享,我们后续再深入讨论。
IOSurface 提供的主要接口:
通过 IOSurface 可以创建 CVPixelBuffer:
CVPixelBufferCreateWithIOSurface()
;通过 IOSurface 创建纹理:
MTLDevice.makeTexture(descriptor:iosurface:plane:)
;
CVPixelBuffer
通过指定 kCVPixelBufferIOSurfacePropertiesKey
属性,可创建基于 IOSurface 后端的 CVPixelBuffer,进而开启内存共享:
CFDictionaryRef properties = CFDictionaryCreate(kCFAllocatorDefault,
NULL, NULL, 0,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
CFMutableDictionaryRef attrs = CFDictionaryCreateMutable(kCFAllocatorDefault,
1,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
CFDictionarySetValue(attrs, kCVPixelBufferIOSurfacePropertiesKey, properties);
CVPixelBufferRef renderTarget;
CVPixelBufferCreate(kCFAllocatorDefault, width, height,
kCVPixelFormatType_32BGRA, attrs, &renderTarget);
如果未设置 IOSurface 属性,CVPixelBufferGetIOSurface()
会返回 nil
;
而系统 AVCapture 和 VideoToolBox 输出的 CVPixelBuffer 自带 IOSurface 属性。
不过,读写 CVPixelBuffer 需要使用锁:
CVPixelBufferLockBaseAddress()
加锁;CVPixelBufferUnlockBaseAddress()
解锁;
并且支持引用计数:
CVPixelBufferRetain()
持有,引用计数 +1;CVPixelBufferRelease()
释放,引用计数 -1;
TextureCache
iOS 有 TextureCache 机制:
CVOpenGLESTextureCacheCreate()
用于创建缓存;通过
kCVOpenGLESTextureCacheMaximumTextureAgeKey
可设置缓存有效期,默认 1s;CVOpenGLESTextureCacheFlush()
用于释放缓存;
而 TextureCache 还支持通过 CVPixelBuffer 创建纹理,具体可参考前文 “iOS 渲染 RGB/YUV”;
iOS 不需要外部纹理
这样,iOS 就完美解决了视频采集/渲染/编码过程中跨 CPU/GPU 数据拷贝的性能问题:
CVPixelBuffer 可设置 IOSurface 后端,利用其跨 CPU/GPU、跨进程、跨 API 的内存共享能力;
TextureCache 可通过 CVPixelBuffer 创建纹理,通过 CVPixelBuffer 即可直接读写纹理数据。
所以,iOS 既不需要外接纹理,大多数情况也不需要用到 PBO。
至于性能,据第三方测试,这种方式性能不仅远高于 glReadPixels()
,甚至高于 PBO。
更多相关内容可参考:
下一代图形 API
Vulkan
Android 7.0 才提供 Vulkan 支持,少数设备可通过 SwiftShader 在 CPU 层面模拟 Vulkan 的 API,但性能肯定跟不上。
而国内主流 Android APP 最低版本还在支持 5.0,所以 Vulkan 真正铺开还需要一段时间。
Metal
Apple 早在 iOS 8 就推出了 Metal,并且将 OpenGL ES 的 API 标记为 deprecated。
迁移
Vulkan 目前个人还没有项目实践,Metal 倒是有,18 年也输出过一篇文章。
如果想所有平台统一一套图形 API,可以考虑 MoltenVK,它提供了一套基于 Metal 的 Vulkan 实现。
更多关于 Vulkan 和 MoltenVK 的内容,后续有机会再单独展开。
视频硬编码
Android 视频编码
Android 视频硬编码主要是 MediaCodec
;
MediaCodec API 兼容问题
首先第一个坑,就是大量接口存在版本兼容问题,而且还跟状态有关!截个局部图大家感受下:
可以想象 5.0 普及前的时代,使用 MediaCodec
简直是地狱模式,只能各种异常处理:
try {
if (encodeLevel > 1) {
format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh);
format.setInteger("level", MediaCodecInfo.CodecProfileLevel.AVCLevel41);
} else if (encodeLevel > 0) {
format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileMain);
format.setInteger("level", MediaCodecInfo.CodecProfileLevel.AVCLevel32);
} else {
mMediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
}
} catch (Exception e) {
encodeLevel--;
}
MediaCodec 的“黑科技”
从 API 18 开始,MediaCodec
支持将 Surface
作为输入;
而前面提到,外部纹理能直接将摄像头输出渲染为纹理,并且将 BufferQueue 中的数据标记为 OpenGL 能识别的标记;
这里涉及到一个生产者-消费者的模型:
Camera 输出的外部纹理为 GraphicBuffer 生产者;
MediaCodec 为 GraphicBuffer 消费者;
这样,整个 采集->渲染->编码 流程就在 GPU 闭环,既避免了数据跨 CPU/GPU 拷贝,同时编码器也不用手动输入 buffer 数据。
iOS 视频编码
iOS 视频硬编码主要是 VideoToolBox
,流程比较简单:
VTCompressionSessionCreate()
创建编码会话:codecType
参数:传kCMVideoCodecType_HEVC
表示 H.265 硬编码,A10 芯片(iPhone 7)及以上设备支持;VTCompressionOutputCallback
参数:编码后的帧数据回调;
VTSessionSetProperty()
设置编码参数;VTCompressionSessionPrepareToEncodeFrames()
准备编码;VTCompressionSessionEncodeFrame()
进行编码;VTCompressionSessionCompleteFrames()
终止编码会话;VTCompressionSessionInvalidate()
销毁编码会话;
至于实时采集编码性能问题,前面提过,iOS 有 IOSurface/CVPixelBuffer/TextureCache,不存在 CPU/GPU 数据拷贝性能问题,也就不需要再引入什么 “黑科技”。