本文门槛较高,因此行文看起来会乱一些,如果你看到某处能会心一笑请马上联系我开始摆龙门阵 如果你跟随这篇文章实现了播放器,那你会得到一个高效率,低cpu占用(单路720p视频解码播放占用1%左右cpu),且代码和引用精简(无其他托管和非托管的dll依赖,更无需安装任何插件,你的程序完全绿色运行);并且如果硬解不可用,切换到软件是自动过程
首先需要准备好visual studio/msys2/ffmpeg源码/dx9sdk。因为我们要自己编译ffmpeg,并且是改动代码后编译,ffmpeg我们编译时会裁剪。
- ffmpeg源码大家使用4.2.1,和我保持同步,这样比较好对应,下载地址为ffmpeg-4.2.1.tar.gz
- msys2安装好后不需要装mingw和其他东西,只需要安装make(见下方图片;我们编译工具链会用msvc而非mingw-gcc)
msys2安装make
- visual studio版本按道理是不需要新版本的,应该是2008-2019都可以(不过还是得看看ffmpeg代码里是否用了c99 c11等低版本不支持的东西),vs需要安装c 和c#的模块(见下方图片;应该也不需要特意去打开什么功能)
vs所需功能模块
- dx9的sdk理论上是不用安装的(如果你是高手,可以用c#的ilgenerator直接写calli;亦或者写unsafe代码直接进行内存call,文章最后我会为大家揭秘如何用c#调用c 甚至com组件)。我用了directx的managecode,由官方为我们做了dx的调用(见下方图片)
安装好dx的sdk后我们得到c#的托管引用dll
第二步是修改ffmpeg源码并编译,我们要修改的源码只有一个文件的十余行,而且是增量修改。
修改的文件位于libavutil/hwcontext_dxva2.c文件,我先将修改部分贴出来然后再给大家解释
hwcontext_dxva2.c修改部分
代码中dxva2_device_create9_extend函数是我新加入的,并且在dxva2_device_create函数(这个函数是ffmpeg原始流程中的,我的改动不影响原本任何功能)中适时调用;简单来说,原来的ffmpeg也能基于dxva2硬件解码,但是它没法将解码得到的surface用于前台播放,因为它创建device时并未指定窗口和其他相关参数,大家可以参考我代码实现,我将窗口句柄传入后创建过程完全改变(其他人如果使用我们编译的代码,他没有传入窗口句柄,就执行原来的创建,因此百分百兼容)。
原始文件(版本不一致,仅供参考)
(ps:在这里我讲一下网络上另外一种写法(两年前我也用的他们的,因为没时间详细看ffmpeg源码),他们是在外面创建的device和surface然后想办法传到ffmpeg内部进行替换,这样做有好处,就是不用自己修改和编译ffmpeg,坏处是得自己维护device和surface。至于二进制兼容方面考虑,两种做法都不是太好)
代码修改完成后我们使用msys2编译
- 首先是需要把编译器设置为msvc,这个步骤通过使用vs的命令行工具即可,如下图
打开vs的编译工具
- 然后是设置msys2继承环境变量(这样make时才能找到cl/link)
设置msys继承环境变量
将msys自带link重命名避免冲突
- 打开msys,查看变量是否正确
检查变量正确性
- 编译ffmpeg
./configure --enable-shared --enable-small --disable-all --disable-autodetect --enable-avcodec --enable-decoder=h264 --enable-dxva2 --enable-hwaccel=h264_dxva2 --toolchain=msvc --prefix=host
make && make install
cmake和make语句
编译完成后头文件和dll在host文件夹内(编译产出的dll也是clear的,不依赖msvc**.dll)
编译产出
在C#中使用我们产出的方式需要使用p/invoke和unsafe代码。
我先贴出我针对ffmpeg写的一个工具类,然后给大家稍微讲解一下
FFHelper.cs
上文中主要有几个地方是知识点,大家做c#的如果需要和底层交互可以了解一下
- 结构体的使用 结构体在c#与c/c 基本一致,都是内存连续变量的一种组合方式。与c/c 相同,在c#中,如果我们不知道(或者可以规避,因为结构体可能很复杂,很多无关字段)结构体细节只知道结构体整体大小时,我们可以用Pack=1,SizeConst=来表示一个大小已知的结构体。
- 指针的使用 c#中,有两种存储内存地址(指针)的方式,一是使用interop体系中的IntPtr类型(大家可以将其想象成void*),一是在不安全的上下文(unsafe)中使用结构体类型指针(此处不讨论c 类指针)
- unsafe和fixed使用 简单来说,有了unsafe你才能用指针;而有了fixed你才能确保指针指向位置不被GC压缩。我们使用fixed达到的效果就是显式跳过了结构体中前部无关数据(参考上文中AVCodecContext等结构体定义),后文中我们还会使用fixed。
现在我们开始编写解码和播放部分(即我们的具体应用)代码
FFPlayer.cs
下面讲解代码最主要的三个部分
- 初始化ffmpeg 主要在静态块和构造函数中,过程中我没有将AVPacket和AVFrame局部化,很多网上的代码包括官方代码都是局部化这两个对象。我对此持保留意见(等我程序报错了再说)
- 将收到的数据送入ffmpeg解码并将拿到的数据进行展示 这里值得一提的是get_format,官方有一个示例,下图
官方的硬解码示例
它有一个get_format过程(详见215行和63行),我没有采用。这里给大家解释一下原因:
这个get_format的作用是ffmpeg给你提供了多个解码器让你来选一个,而且它内部有一个机制,如果你第一次选的解码器不生效(初始化错误等),它会调用get_format第二次(第三次。。。)让你再选一个,而我们首先认定了要用dxva2的硬件解码器,其次,如果dxva2初始化错误,ffmpeg内部会自动降级为内置264软解,因此我们无需多此一举。
- 发现解码和播放过程中出现异常的解决办法
- 不支持硬解 代码中已经做出了一部分兼容,因为baseline的判定必须解出sps/pps才能知道,因此这个错误可能会延迟爆出(不过不用担心,如果此时报错,ffmpeg会自动降级为软解)
- 窗体大小改变 基于DirectX中设备后台缓冲的宽高无法动态重设,我们只能在控件大小改变时推倒重来。如若不然,你绘制的画面会进行意向不到的缩放
- 网络掉包导致硬件解码器错误 见代码
- 其他directx底层异常 代码中我加了一个try-catch,捕获的异常类型是DirectXException,在c/c 中,我们一般是调用完函数后会得到一个HRESULT,并通过FAILED宏判定他,而这个步骤在c#自动帮我们做了,取而代之的是一个throw DirectXException过程,我们通过try-catch进行可能的异常处理(实际上还是推倒重来)
番外篇:C#对DiretX调用的封装 上文中我们使用DirectX的方式看起来即非COM组件,又非C-DLL的P/Invoke,难道DirectX真有托管代码? 答案是否定的,C#的dll当然也是调用系统的d3d9.dll。不过我们有必要一探究竟,因为这里面有一个隐藏副本
首先请大家准备好ildasm和visual studio,我们打开visual studio,创建一个c 工程(类型随意),然后新建一个cpp文件,然后填入下面的代码
测试代码
如果你能执行,你会发现输出是88;然后我们使用ildasm找到StrechRectangle的代码
ildasm中的呈现
你会发现也有一个 88的过程,那么其实道理就很容易懂了,c#通过calli(CLR指令)可以执行内存call,而得益于微软com组件的函数表偏移量约定,我们可以通过头文件知道函数对于对象指针的偏移(其实就是一个简单的ThisCall)。具体细节大家查阅d3d9.h和calli的网络文章即可。