几个月前,我获得了一个工作任务,要求我开发一个自定义的、低延迟的视频播放器。在此之前,我只短暂的用过 FFmpeg,完全没接触过 DirectX 11,但我觉得应该不会太难,因为 FFmpeg 非常受欢迎,DirectX 11 也已经存在了很长时间了,而且又不需要创建清晰的 3D 图形或其他复杂的东西。

当时我觉得应该能找到很多例子,我可以从例子中学习一些类似于解码和渲染视频之类的基本操作。

事实证明并没有。

因此就有了本文。

希望本文可以帮助那些没有 FFmpeg 或 DirectX 11 使用经验却需要开发视频播放器的用户,看了这篇文章就不用像我一样这么费功夫啦。

在正式开始学习之前,我们要先做一些基础准备工作。

  • 我提供非常简化的代码样例。我省去了返回代码检查、错误处理等步骤。我认为代码样本就只是 样本 (我本想提供更充实的示例,但因为涉及到知识产权等问题)。

  • 我不介绍硬件加速视频解码/渲染的原理,因为这有点超出本文的范畴。而且,大家也能找到很多其他资源,比我解释得好。

  • FFmpeg 支持几乎所有的协议和编码格式。RTSP、UDP 和使用 H264 和 H265 编码的视频都可以使用这些样本,我相信很多其他程序也适用。

  • 我创建的项目基于 CMake,不依赖 Visual Studio 的构建系统(因为我们也需要支持非 DX 渲染器),所以有点麻烦。

事不宜迟,我们开始吧!

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

这一步骤几乎只用 FFmpeg 就可以完成。只需设置格式上下文、编解码器上下文和 FFmpeg 需要的其他结构即可。设置方面我主要借鉴了这个示例和另一个叫作 Moonlight 的项目的源代码。

AVCodecContext
// 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。
AV_PIX_FMT_NV12AV_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()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

临时这样处理没有问题,但是不能作为长期解决方案,主要有两个问题。

AVFrame“d3d11va”“dxva2”frame->datauint8_t*“d3d11va”sws_scale()

金无足赤,事无完美,但至少我们现在已经解码了帧,可以将帧放到位图上而且能看到。

FFmpeg 部分到此结束,接下来是在 DirectX 11 中进行渲染。

步骤#3:设置 DirectX 11 渲染。

注意:DX11 与 DX9 完全不同!!!

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

fxc.exe/Fh
步骤#4:把颜色换为纹理。
COLORTEXCOORD
XMFLOAT2XMFLOAT4

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

步骤#5:渲染实际的帧。
ID3D11Texture2DDXGI_FORMAT_R8G8B8A8_UNORMmemcpywidth_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:渲染实际帧——这次,似乎是正确地渲染。
“d3d11va”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);
AVCodecContextID3D11Device

现在,当我们将解码帧发送到渲染器时,不需要把它们传输到 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
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()));
memcpyCopySubresourceRegion()
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()