使用FFmpeg和DirectX 11流式传输视频

几个月前,我当时的工作任务是开发定制的低延迟视频播放器。在此之前,我仅简单用过FFmpeg并且没用过DirectX 11,但我想应该并不难。FFmpeg非常受欢迎,而 DirectX 11已经存在了一段时间,这(目前)还不像能够实现我需要创建的那种清晰的3D图形或其他任何东西。

会有大量的示例说明如何做一些例如解码和渲染视频这类基本的事情吗?

没有。所以请看本文。

下一个没有FFmpeg或DirectX 11经验,却需要这样做的可怜人,就不必为了要将一些视频发到屏幕上而抓狂。

在我们获得诀窍前,只需做一些最基本的准备工作。

  • 提供 非常 简化的代码示例。我省去了返回代码检查、错误处理等步骤。我的观点是,代码样本就是: 样本。 (我本提供更多充实的示例,但你知道的,这涉及到知识产权及所有其他内容。)

  • 我将不介绍硬件加速视频解码/渲染的原理,因为这超出了本文的范围。此外,还有很多资源能解释得比我好。

  • FFmpeg支持几乎所有协议和编码格式。RTSP和UDP都可以使用这些样本,以及使用H264和H265编码的视频。我敢肯定,还有很多程序都可以对其进行使用。

  • 我创建的项目基于CMake,并且不依赖Visual Studio的构建系统(因为我们也需要支持非DX渲染器),这使事情变得有点困难,这就是为什么我认为我会提到它。

事不宜迟,让我们开始吧!

步骤#1:设置流源和视频解码器。

这几乎完全是FFmpeg的东西。只需设置格式上下文,编解码器上下文以及FFmpeg需要的所有其他结构即可。对于设置,我非常依赖此示例以及另一个名为Moonlight的项目的源代码。

注意,你必须以某种方式在 AVCodecContext 上提供硬件设备类型。我选择以与FFmpeg示例相同的方式执行此操作:基本字符串。

// initialize stream
const std::string hw_device_name = "d3d11va";
AVHWDeviceType device_type = av_hwdevice_find_type_by_name(hw_device_name.c_str());

// set up codec context

AVBufferRef* hw_device_ctx;
av_hwdevice_ctx_create(&hw_device_ctx, device_type, nullptr, nullptr, 0);
codec_ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx);

// open stream

一旦设置完成,实际的解码就非常简单了。只需从流源中检索AVPackets,然后使用编解码器将它们解码为AVFrame。

AVPacket* packet = av_packet_alloc();
av_read_frame(format_ctx, packet);
avcodec_send_packet(codec_ctx, packet);

AVFrame* frame = av_frame_alloc();
avcodec_receive_frame(codec_ctx, frame);

这些只是简化,不需要花费很多时间将一些东西拼凑在一起。尽管我还无法在屏幕上呈现任何内容,但我想验证自己是否在生成有效的解码帧,所以我只是将它们写到位图文件中,然后以这种方式进行检查。

这里有一个小问题。

步骤2:将NV12转换为RGBA。

要创建位图(事实证明,是渲染为DX11交换链),我需要帧是RGBA格式。但是解码器以NV12格式吐出帧,所以我使用FFmpeg的swscaleAV_PIX_FMT_NV12 转换为 AV_PIX_FMT_RGBA

设置 SwsContext 的过程就像调用单个函数一样简单。

SwsContext* conversion_ctx = sws_getContext(
        SRC_WIDTH, SRC_HEIGHT, AV_PIX_FMT_NV12,
        DST_WIDTH, DST_HEIGHT, AV_PIX_FMT_RGBA,
        SWS_BICUBLIN | SWS_BITEXACT, nullptr, nullptr, nullptr);

当然,要使用 sws_scale() ,我们需要将帧从GPU传输到CPU。我是使用 av_hwframe_transfer_data() 中FFmpeg的内置功能做到这一点的。有很多这样的例子

// decode frame
AVFrame* sw_frame = av_frame_alloc();
av_hwframe_transfer_data(sw_frame, frame, 0);
sws_scale(conversion_ctx, sw_frame->data, sw_frame->linesize, 
          0, sw_frame->height, dst_data, dst_linesize);

sw_frame->data = dst_data
sw_frame->linesize = dst_linesize
sw_frame->pix_fmt = AV_PIX_FMT_RGBA
sw_frame->width = DST_WIDTH
sw_frame->height = DST_HEIGHT

暂时这样做还不错,但是作为一个长期解决方案,存在两个主要问题。

  1. 我在 AVFrame 中需要的是一个简单易懂的字节数组,使用 “d3d11va” 作为硬件设备名称为我们提供了简单的字节数组以外的东西,所以我将硬件设备名称更改为 “dxva2” 。现在, frame->data 仅仅是 uint8_t* 形式上的位图。目前可以使用它,但是作为一种长期解决方案, 使用 “d3d11va” 是基本上错了的要点。

  2. 为了调用 sws_scale() 并将帧转换为RGBA格式,我们需要将帧从GPU移到CPU。目前还可以这样使用,但将来绝对是我们希望删除的内容。

虽然不是完美的,但至少我们现在已经解码了帧,可以将它们放到位图上并且自己能看到。

部分FFmpeg就是这样(目前),在DirectX 11中进行渲染。

步骤#3:设置DirectX 11呈现。

如果你还不知道,这是给你的警告:DX11与DX9完全不同,真的完全不同。

在多次尝试显示绿色或黑色屏幕以外的内容失败之后,我复制并粘贴了示例,以便从工作代码开始。在那之后,将三角形变成正方形的任务变得异常复杂。(我选择了4个顶点,6个索引的选项。)

相比于编译运行时的着色器,我选择从编译 的编译 时间运行它们。有一秒,我以为我必须要有一个第三方库来执行此操作,但其实需要做的只是在CMakeLists.txt文件中的几行代码。查找 fxc.exe 可执行文件,并使用适当的选项执行命令以编译着色器。(我使用 /Fh 将它们编译为自动生成的标头。)

步骤#4:交换颜色以获得纹理。

一旦我完成了彩虹方块的工作,就只需要在定义的输入布局中把 COLOR 切换成 TEXCOORD 即可。这意味着需要更改一些内容:

  • 现在,顶点结构的纹理坐标是 XMFLOAT2x,y ),替代了颜色 XMFLOAT4 坐标( rgba )。

  • 像素着色器需要从纹理中采样颜色,而不仅仅是使用提供的颜色,这意味着需要一个采样器。

  • 请记住,纹理坐标和位置坐标是不同的。最初我并不知道,这给我带来很多麻烦。

一旦能够渲染基本的静态JPEG图像,我就知道自己离成功不远了,剩下的就是将实际的位图从帧传输到共享纹理。

步骤#5:渲染实际帧。

由于我们的帧仍然是RGBA格式的简单字节数组,而且 ID3D11Texture2D 采用的是 DXGI_FORMAT_R8G8B8A8_UNORM 格式,因此简单 memcpy 就可以了。我们需要复制的数组长度仅是帧中字节的计算: width_in_pixels * height_in_pixels * bytes_per_pixel

注意,我们还需要调用设备上下文的 Map() 以获取一个指针,这样我们就能访问纹理的基础数据。

// decode and convert frame

static constexpr int BYTES_IN_RGBA_PIXEL = 4;

D3D11_MAPPED_SUBRESOURCE ms;
device_context->Map(m_texture.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &ms);

memcpy(ms.pData, frame->data[0], frame->width * frame->height * BYTES_IN_RGBA_PIXEL);

device_context->Unmap(m_texture.Get(), 0);

// clear the render target view, draw the indices, present the swapchain

达到这一点,并能在屏幕上观看实况视频令人十分开心。说实话,我已经举起双手,并赞叹编码之神眷顾了我。

可惜。我的工作还远远没有结束。现在,该回头解决我在步骤2中遇到的两个问题了。

步骤#6:渲染实际帧……但是,这次正确了。

从研究开始,我就知道向FFmpeg提供 “d3d11va” 硬件设备,DirectX 11渲染器可以以轻松消化的方式输出FFmpeg 。但是我怎样才能做到这一点呢?

我们需要正确地初始化d3d11va硬件设备的上下文,这 意味着FFmpeg解码器需要了解其正在使用的D3D11设备。

AVBufferRef* hw_device_ctx = av_hwdevice_ctx_alloc(AV_HWDEVICE_TYPE_D3D11VA);

AVHWDeviceContext* device_ctx = reinterpret_cast<AVHWDeviceContext*>(hw_device_ctx->data);

AVD3D11VADeviceContext* d3d11va_device_ctx = reinterpret_cast<AVD3D11VADeviceContext*>(device_ctx->hwctx);

// m_device is our ComPtr<ID3D11Device>
d3d11va_device_ctx->device = m_device.Get();

// codec_ctx is a pointer to our FFmpeg AVCodecContext
codec_ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx);

av_hwdevice_ctx_init(codec_ctx->hw_device_ctx);

看起来有很多设置,但是最终,我们要做的只是在解码器 AVCodecContext 中,将指针存储到渲染器的 ID3D11Device 。这就是使解码器将帧输出为DX11纹理的过程。

现在,当我们将解码后的帧发送到渲染器时,不需要将它们传输到CPU,也不需要将它们转换为RGBA,就可以简单地做到这一点:

ComPtr<ID3D11Texture2D> texture = (ID3D11Texture2D*)frame->data[0];

但是,我们离完成任务还差远了。

我们需要将像素格式转换移至GPU。 开始时,我们的交换链无法渲染NV12帧,这意味着从NV12到RGBA的转换仍然必须发生在 某个地方 。现在,它将发生在GPU中,而不是发生在CPU中——在像素着色器中具体来说。

这是合乎逻辑的;我们不能再对纹理中的某个位置进行采样了,因为我们的纹理不再包含在RGBA中。为了使我们的像素着色器为每个像素返回正确的RGBA值,需要从纹理的YUV值中进行 计算

这意味着我们需要升级像素着色器,以使用NV12并输出RGBA。你可以自己派生这样的着色器,也可以只使用已经编写的着色器

添加另一个着色器资源视图。 尽管RGBA像素着色器将单个着色器资源视图作为输入,但NV12像素着色器实际上需要两个:色度和亮度。因此,我们需要将一个纹理拆分为两个着色器资源视图。(在此之前,我不明白为什么DirectX需要区分纹理和着色器资源视图,但我很高兴他们这么做。)

// DXGI_FORMAT_R8_UNORM for NV12 luminance channel

D3D11_SHADER_RESOURCE_VIEW_DESC luminance_desc = CD3D11_SHADER_RESOURCE_VIEW_DESC(m_texture, D3D11_SRV_DIMENSION_TEXTURE2D, DXGI_FORMAT_R8_UNORM);

m_device->CreateShaderResourceView(m_texture, &luminance_desc,  &m_luminance_shader_resource_view); 

// DXGI_FORMAT_R8G8_UNORM for NV12 chrominance channel

D3D11_SHADER_RESOURCE_VIEW_DESC chrominance_desc = CD3D11_SHADER_RESOURCE_VIEW_DESC(texture,  D3D11_SRV_DIMENSION_TEXTURE2D, DXGI_FORMAT_R8G8_UNORM);

m_device->CreateShaderResourceView(m_texture, &chrominance_desc, &m_chrominance_shader_resource_view);

当然,我们还需要确保允许我们的像素着色器访问这些色度和亮度通道。

m_device_context->PSSetShaderResources(0, 1, m_luminance_shader_resource_view.GetAddressOf());

m_device_context->PSSetShaderResources(1, 1, m_chrominance_shader_resource_view.GetAddressOf());

我们需要打开纹理作为共享资源。 我们保留在渲染器中的 ID3D11Texture2D 对象是FFmeg框架和着色器资源视图之间真正的桥梁。我们将新框架复制到其中,并从中提取着色器资源视图。这是一种共享资源,我们需要这样做。

ComPtr<IDXGIResource> dxgi_resource;

m_texture->QueryInterface(__uuidof(IDXGIResource), reinterpret_cast<void**>(dxgi_resource.GetAddressOf()));

dxgi_resource->GetSharedHandle(&m_shared_handle);

m_device->OpenSharedResource(m_shared_handle, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(m_texture.GetAddressOf()));

我们需要更改复制接收到的纹理的方式。 每次渲染帧时创建新的着色器资源视图是十分昂贵的,并且 memcpy 不再可行,因为它不能让我们轻松访问纹理的基础数据。我认为将接收到的帧复制到纹理的正确方法是使用内置的DirectX函数,例如 CopySubresourceRegion()

ComPtr<ID3D11Texture2D> new_texture = (ID3D11Texture2D*)frame->data[0];
const int texture_index = frame->data[1];

m_device_context->CopySubresourceRegion(
        m_texture.Get(), 0, 0, 0, 0, 
        new_texture.Get(), texture_index, nullptr);

做完这些更改之后,我就可以放心地使用 av_hwframe_transfer_data()sws_scale() 功能,在最后的最后,向每一个完全集成FFmpeg-DirectX11的视频播放器问好。

原文作者 Ori Gold
原文链接 https://medium.com/swlh/streaming-video-with-ffmpeg-and-directx-11-7395fcb372c4

推荐阅读
作者信息
AgoraTechnicalTeam
TA 暂未填写个人简介
文章
153
相关专栏
本专栏仅用于分享音视频相关的技术文章,与其他开发者和 Agora 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。