技术背景
对于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_CONTEXT
if (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_CONTEXT
if (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
旧版ffmpeg具体实现可见:[https://gitpub.sietium.com/tools/toolkits/ffvademo/-/tree/bridge]。差异化对比后就可见新版本的ffmpeg已经帮我们做了很多背后的事情。
进入正式的解码循环并解完一帧后,关键点就是我们就可以从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 获取源码。