使用 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,我是用 FFmpeg 的内置av_hwframe_transfer_data() 完成这一步的,这样的例子有很多。

// 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 渲染器可以轻松消化的方式输出 AVFrame。但怎样才能做到这一点呢?

我们需要正确地初始化 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

注册登录 后评论
    // 作者
    声网技术社区 发布于 声网开发者社区
    • 0
    // 相关帖子
    Coming soon...
    • 0
    使用 FFmpeg 和 DirectX 11 进行视频推流声网技术社区 发布于 声网开发者社区