技术背景
对于ffmpeg硬解码后渲染常见的做法是解码后通过av_hwframe_transfer_data
方法将数据从GPU拷贝到CPU,然后做一些转换处理用opengl渲染,必然涉及到譬如类似glTexImage2D
的函数将数据上传到GPU。而这样2次copy就会导致CPU的使用率变高,且GPU没有被充分利用。
基于此,我们可以使用下面的0-copy技术可以将VAAPI解码后的数据直接通过GPU渲染,1080P@60FPS的视频CPU使用率可以降低到5%以内。
知识背景
一、ffmpeg 硬件解码
首先假设读者已经了解或具备使用 ffmpeg 进行硬件解码的相关知识。如果不了解的话建议先学习ffmpeg官方示例:https://github.com/FFmpeg/FFmpeg/blob/master/doc/examples/hw_decode.c
二、vaapi 解码流程
//以下代码位于:/usr/include/va/va.h* dpy = vaGetDisplayDRM(fd);* vaInitialize(dpy, ...);** // Create surfaces required for decoding and subsequence encoding* vaCreateSurfaces(dpy, VA_RT_FORMAT_YUV420, width, height, &surfaces[0], ...);** // Set up a queue for the surfaces shared between decode and encode threads* surface_queue = queue_create();** // Create decode_thread* pthread_create(&decode_thread, NULL, decode, ...);** // Create encode_thread* pthread_create(&encode_thread, NULL, encode, ...);** // Decode thread function* decode() {* // Find the decode entrypoint for H.264* vaQueryConfigEntrypoints(dpy, h264_profile, entrypoints, ...);** // Create a config for H.264 decode* vaCreateConfig(dpy, h264_profile, VAEntrypointVLD, ...);** // Create a context for decode* vaCreateContext(dpy, config, width, height, VA_PROGRESSIVE, surfaces,* num_surfaces, &decode_context);** // Decode frames in the bitstream* for (;;) {* // Parse one frame and decode* vaBeginPicture(dpy, decode_context, surfaces[surface_index]);* vaRenderPicture(dpy, decode_context, buf, ...);* vaEndPicture(dpy, decode_context);* // Poll the decoding status and enqueue the surface in display order after* // decoding is complete* vaQuerySurfaceStatus();* enqueue(surface_queue, surface_index);* }* }
如上,vaapi硬解码的基本流程为:
- 创建vadisplay.
- 查询profile和Entrypoint。
- 根据profile创建config和context。
- 通过vaBeginPicture、vaRenderPicture、vaEndPicture完成解码(真正的硬解码是发生在vaEndPicture的调用上)。
三、vaapi硬解码中 VADisplay 和 VASurfaceID 的关系
VADisplay:它是一个代表和管理整个VAAPI会话的上下文对象。VADisplay负责与硬件加速器的通信,创建会话,查询硬件加速器的功能,并且是进行各种VAAPI调用的起点。在X Window系统中,VADisplay通常与一个X11 Display连接关联,因为它需要与视频输出设备进行交互,但它也可以在不直接使用X11的情况下使用,比如通过DRM直接与GPU交互。
VASurfaceID: 这是一个标识符,代表特定的解码后的视频帧或者用于编码、处理的图像表面。VASurfaceID实质上是内存中存放图像数据的缓冲区,这些缓冲区是硬件加速的,并且对应于VAAPI内部的图像资源。
彼此之间的关系:
- VADisplay用于创建和管理VASurfaceID。在解码视频流的过程中,首先需要通过VADisplay来创建一系列的VASurfaceID,这些VASurface将用于存储解码出来的视频帧。
- 解码视频时,解码器会将视频帧解码到由VASurfaceID代表的表面上。这些表面被管理在VADisplay的上下文中,以便能够让解码器知道将解码数据放置在哪里。
- 在视频渲染或播放阶段,VADisplay能够利用关联的输出系统(比如X11或者Wayland)来显示VASurfaceID所代表的视频帧。
总的来说,VADisplay是管理和执行解码会话的接口,而VASurfaceID则是在这一过程中实际存储解码数据的缓冲区的标识。两者配合使用,实现了硬件加速视频的解码和显示过程。
完整流程图
核心点释义
VA-X11 方式渲染
如上图所示,黑色线条为ffmpeg常规解码流程。红色线框加入了为实现0-copy渲染所需要的必要步骤。蓝色线框为利用libva直接渲染到x11窗口。橙色线框为借助egl和opengl渲染。主要增加了两个核心点。第一是在打开解码器前的配置,第二是解码后如何从avframe得到vasurfaceID并零复制渲染。
在①②③处,首先使用Xopendisplay得到默认显示设备指针,其次并创建出要显示的目标窗口。此处其实也并不一定要直接用Xlib库创建窗口,作为不依赖其他图形库的Demo程序,用X11窗口是比较合适的,当然也可以用qt创建QWidget,用其句柄作为之后的渲染目标。然后用vaGetDisPlay得到VADisPlay对象。VADisPlay对象比较重要,使用vaapi硬解码,ffmpeg要求我们必须将申请的vadisplay指针赋值给AVCodecContext中的hw_device_ctx的hwctx的display,否则在后面是无法得到有效的vasurfaceID。这也能理解,作为承载vaapi解码上下文的重要对象,参考“知识背景”中的第二节[vaapi 解码流程],vaBeginPicture、vaEndPicture等函数都需要依赖这个vadisplay,而这些函数其实被实现在ffmpeg解码函数中的。对于老版本的fffmpeg,vaEndPicture等函数是实现在 avcodec_decode_video2() 中。得到VADisplay后就是用vaInitialize对vaapi进行初始化了。
上面的流程图是新版本(4.x)ffmpeg的vaapi硬解初始化配置方法。对于老版的ffmpeg就比较复杂了。需要使用vaapi_context,用户自己实现createconfig、createcontext,得到configID和contextid,并将vaapi_context赋值给AVCodecContext的hwaccel_context(下面代码132行)。可以看ffmpeg源码中关于新旧版本context(old_context)的实现区别:
以下代码位置:<https://github.com/FFmpeg/FFmpeg/blob/release/4.4/libavcodec/vaapi_decode.c>int ff_vaapi_decode_init(AVCodecContext *avctx){VAAPIDecodeContext *ctx = avctx->internal->hwaccel_priv_data;VAStatus vas;int err;ctx->va_config = VA_INVALID_ID;ctx->va_context = VA_INVALID_ID;#if FF_API_STRUCT_VAAPI_CONTEXTif (avctx->hwaccel_context) {av_log(avctx, AV_LOG_WARNING, "Using deprecated struct ""vaapi_context in decode.\n");ctx->have_old_context = 1;ctx->old_context = avctx->hwaccel_context;// Really we only want the VAAPI device context, but this// allocates a whole generic device context because we don't// have any other way to determine how big it should be.ctx->device_ref =av_hwdevice_ctx_alloc(AV_HWDEVICE_TYPE_VAAPI);if (!ctx->device_ref) {err = AVERROR(ENOMEM);goto fail;}ctx->device = (AVHWDeviceContext*)ctx->device_ref->data;ctx->hwctx = ctx->device->hwctx;ctx->hwctx->display = ctx->old_context->display;// The old VAAPI decode setup assumed this quirk was always// present, so set it here to avoid the behaviour changing.ctx->hwctx->driver_quirks =AV_VAAPI_DRIVER_QUIRK_RENDER_PARAM_BUFFERS;}#endif#if FF_API_STRUCT_VAAPI_CONTEXTif (ctx->have_old_context) {ctx->va_config = ctx->old_context->config_id;ctx->va_context = ctx->old_context->context_id;av_log(avctx, AV_LOG_DEBUG, "Using user-supplied decoder ""context: %#x/%#x.\n", ctx->va_config, ctx->va_context);} #endif
进入正式的解码循环并解完一帧后,关键点就是我们就可以从AVFrame的data[3]中得到vasurfaceID。这点我们可以从ffmpeg源码中得到验证,ffmpeg内部map这个VAImage时也是同样的操作。
以下代码位置:https://github.com/FFmpeg/FFmpeg/blob/release/4.4/libavutil/hwcontext_vaapi.c
得到VASurfaceID后,就可以使用VAPutSurface函数将指定的视频帧请求渲染到之前申请的x11窗口句柄上了。
EGL 方式渲染
如果我们想使用opengl进行渲染,稍微麻烦一点。需要借助EGL帮我们得到共享的纹理数据。
如上面流程图中的橙色线框 ⑥ ⑦处,首先我们需要在解码循环前初始化EGL环境和opengl环境。 这块是标准的初始化过程,也不会和VADisPlay、VASurfaceID建立任何的关系。唯一有点关系的是在初始化egl时因为我们要显示到一个可见的窗口中,所以使用eglCreateWindowSurface函数时需要一个X11 window(在流程图的①处我们已经通过XCreateWindow创建了一个),并将其指针作为egl的渲染目标窗口。
重点来到⑧ ⑨ ⑩ ⑪ 。不变的是我们依旧从AVFrame的data[3]中得到VASurfaceID,变化的是我们需要利用libva提供的vaExportSurfaceHandle函数,结合VASurfaceID 导出当前VASurfaceID所对应的VADRMPRIMESurfaceDescriptor
结构体。有了这个结构体后我们可以从里面获取到解码数据的比如平面信息(NV12?YUV420P?)和DRM Prime的文件描述符、宽高、像素格式等。有了这些数据我们就可以用其填充eglCreateImageKHR
所需要的attributes,要注意attributes 必须与 VADRMPRIMESurfaceDescriptor
提供的信息匹配。创建出EGLImage之后,我们就可以用glEGLImageTargetTexture2DOES
将其绑定到我们的opengl(es)纹理之上。之后就可以进行正常的或其他的opengl渲染动作。
至此,vaapi解码出的视频数据已经被0-copy的方式所渲染出来,核心动作全部在GPU上进行,CPU的占用率只占5%以下。
关注公众号 QTShared 获取源码。