聊聊 Android/iOS 的视频采集、渲染、编码

虽然 18 年就做过跨 Android/iOS 双端渲染的项目,但只是单纯地 2D 纹理渲染,不涉及视频流;

而后 19 年参与的图像识别项目,虽然涉及 Camera,但也仅限 Android 侧,而且也不涉及渲染。

近期有机会同时接触到 Android/iOS 直播项目,串联起图像采集/编码/渲染,终于深刻认识到双端图像处理的差异。

下面简单做下梳理。

图像采集

Android 图像采集

Android 的碎片化臭名昭著,在图像采集这块也不例外。

起初有一套 Camera,接口简单,输出格式一般是 NV21,5.0 开始被废弃;

Camera2 5.0 推出,虽然支持的特性更多,但是接口和流程也更复杂:

考虑到这个问题,官方又推出了 CameraX

而在国内,OPPO、华为等第三方厂商还推出了自己的 Camera API。

iOS 图像采集

iOS 只有一套 AVFoundation 的接口,而且流程也相对更简单清晰:

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);
  • 通过 SurfaceSurfaceTexture 创建渲染到屏幕的 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];

GLKViewMTKView 会自动管理 EAGLLayerCAMetalLayer

渲染纹理

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 的 formatGLES20.GL_RGBA,YUV 的 formatGLES20.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(NULLNULL, 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_LUMINANCEGL_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 数据拷贝性能问题,也就不需要再引入什么 “黑科技”。