基于 DirectShow 实现的 Windows 平台虚拟摄像头

本文重点介绍利用 DirectShow 组件的特性实现在 Windows 平台下”虚拟摄像头”功能的方法, 同时简要介绍与探讨 DirectShow 本身的特性.

绝大多数虚拟摄像头从实现方式上来看可以分为两种思路:

  1. 在驱动层面进行开发, 模拟一个 物理摄像头 . 让 OS 以普通物理摄像头的方式调用虚拟摄像头.
  2. 在 OS 平台通用的媒体组件层面进行开发, 模拟一个 视频流数据源 . 让 OS 层之上的应用以普通摄像头的方式调用虚拟摄像头.

其中方案 1. 的优点在于可以在当前平台下完全伪造一个物理摄像头, 只要用户行为没有脱离 OS 的控制范围, 那么这个摄像头都是可用正常使用的. 缺点是驱动开发的难度和工作量很大, 需要针对不同的硬件平台提供不同的驱动程序.

方案 2. 的优点在于开发难度相对较小, 只要针对不同的 OS 提供不同的软件即可, 无需关心硬件的差别. 缺点在于部分不依赖当前 OS 平台通用媒体组件的应用场景是没办法覆盖到的.

文本采取的方案是后者, 这同时也是 OBS 与 Mevo 等团队采用的方案. 对于我们的目标平台 Windows, 自 2005 年其正式成为 Windows Vista 的组件起至今 (2022 年 Windows 11) 就一直作为 Windows 平台下的通用媒体组件. 我们的工作也将围绕 DirectShow 展开.


基础概念

这一大节主要介绍 DirectShow 的历史和基本架构, 暂不涉及虚拟摄像头的具体开发工作. 如果你已经了解过则直接跳过即可.

ActiveMovie, DirectShow 与 DirectX 的关系

DirectShow 的前身是 ActiveMovie, 它的出现是作为微软对 Windows 3.0 时代最流行的媒体平台 QuickTime 的回应. ActiveMovie 最早被微软附加在 Windows 95 的 IE 3.0 中负责播放窗口内的媒体文件, 正如 QuickTime 为 Netscape 提供的服务那样. 此时的它已经确定了将来要作为 Windows 现有视频技术 (VFW, Video For Windows) 的后继者了.

当时的微软正在努力推进一个可以让开发者以一种”直接地”的方式将软硬件相结合的概念, 在这一个目标驱动下的产物就是 DirectX. 这是一个非常庞大繁杂的 SDK. 其中包括 Direct3D, DirectMedia, DirectInput 等许多组件, 并且提供了一套硬件驱动标准. 到了 1998 年, ActiveMovie 被正式合并入 DirectX 5 中作为 DirectMedia 的一部分, 前者也顺理成章地被更名为 DirectShow.

直到 2005 年 Windows Vista 的发布, DirectShow 又被微软从 DirectX 中移除. 但这并不是意味着它的没落, 正相反, 其被移除的原因是经过裁剪后的 DirectShow 已经成为了 Windows 的通用媒体组件. 至此, 基于 DirectShow 开发 Windows 平台下的虚拟摄像头这一目标才有了基础支持.

这里说到的裁剪主要是去掉了文档和 baseclasses 库. 若只是在现有 DirectShow 组件的基础上开发自己的应用程序则这些裁剪没有影响, 但如果是想要开发自己的 DirectShow 组件则这个 baseclasses 库是必要的. 而微软官方不再提供 .lib 静态库, 只提供了源码, 我们需要自行编译.

COM 组件 与 DirectShow 组件的关系

COM (Component Object Model, 组件对象模型) 是微软于 1993 年提出的一种中间件技术. 严格地说它并不是一种 SDK, 不提供任何 API, 甚至不依赖于平台或语言. 它只是一种定义了对象在单个或多个应用程序间行为方式的 编程模式 , 就像 OOP 和 GP 一样只是一种编程模式. 但通常我们说起 COM 往往指代的是 Windows 平台下的 COM 组件, 一个 COM 组件往往可以完成一个基础的工作, 它们可以是微软官方提供的, 也可以是第三方开发者自行实现的.

DirectShow 则是使用 COM 组件的开发方式实现的一套 SDK, 因此二者是包含关系. 一个 DirectShow 组件一定是一个 COM 组件, 反之则不一定成立. 其中一个不同点在于 COM 组件可以以 .dll 动态库形式或 .exe 可执行文件形式出现, 但 DirectShow 组件则必须编译成 .dll 动态库.

COM 组件规范与约定导出函数

上文说到 COM 组件可以是 .dll 动态库也可以是 .exe 可执行文件, 但绝大多数都属于前者. 对于一个符合规范的 COM 组件动态库来说, 其必须实现以下几个导出函数:

  • HRESULT DllRegisterServer(void) : 用于外部注册组件;
  • HRESULT DllUnregisterServer(void) : 用于外部注销组件;
  • HRESULT DllCanUnloadNow(void) : 用于外部检查当前是否允许注销组件;
  • HRESULT DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _Outptr_ LPVOID *ppv) : 用于外部获取组件对象;

此外, 通常也会实现以下导出函数:

  • HRESULT DllInstall(_In_ BOOL bInstall, _In_opt_ PCWSTR pszCmdLine) : 用于适配不同的注册命令.

在 COM 开发模式的设想下, 每一个 COM 组件都应该提供一个独立的业务功能, 且需要尽可能地精简. 而应用程序则可以通过将多个 COM 组件整合在一起实现自己的需求. 除此之外再无其他的约定.

上述函数均返回了 HRESULT 类型, 并且部分函数在参数列表中标注了可选项或返回项. 这是微软曾经推崇的一种函数签名格式规范, 详见 Win32 中的函数签名规范 .

对于一个规范的 COM DLL 组件, 如果我们希望将其公开, 让 OS 内的其他进程都可以通过约定的方式调用, 则需要将动态库信息登记到注册表中. 这需要通过 regsvr32.exe 工具实现, 基本格式如下:

regsvr32 [/u] [/s] dllname

/u : 可选项, 用于注销服务;
/s : 可选项, 用于注册或注销时取消操作结果弹窗提示;
dllname : 必须项, 动态库文件的完整路径;

此外, 每个 COM 组件都有一个属于自己的 GUID 作为唯一的标识.

DirectShow 基本架构

DirectShow 的基本架构见下:

DirectShow 基本架构

Windows 使用了 CPU 的两个特权级, 0 和 3. 0 是内核模式可以直接访问硬件, 3 是用户模式不能直接访问硬件. 整个 DirectShow 都是工作在用户层的. 而它之所以可以 “更直接地” 访问硬件, 原理就在于提供了规范接口, 让软硬件各方可以将自己的功能抽象成一个 Filter 组件. 应用程序将多个的 Filter (对应不同的软硬件) 通过它们自身携带的 “引脚” Pin 相连接, 组成一个有向无环图 FilterGraph. 让媒体数据在这个网状图之间流动, 进而实现整个应用的功能.

例如 :

DirectShow 应用程序示例

上图的 DirectShow 应用程序实现了将麦克风音频输入和网络音频流进行混音, 同时录制到本地并在应用程序中显示波形图的功能. 图中每个矩形对应着一个 Filter 对象, 连线体现出它们之间 Pin 成员的连接情况, 箭头标识了媒体数据的流向. 这张图本身则体现了 FilterGraph 对象的状态, 而 FilterGraph 对象的构造则需要依赖于 FilterGraphManager 对象. 上述这一系列行为通常是在应用程序内实现的.

将媒体数据的流程比作水流, 我们通常把更接近数据源的地方称作”上游”, 把更接近数据输出的地方称作”下游”.

对于一个符合规范的 DirectShow Filter 组件, 我们需要在注册 COM 组件注册/注销的同时注册/注销 DirectShow 组件. DirectShow 将已注册的 Filter 使用一个映射结构来管理, 称作 FilterMapper. FilterMapper 提供了对应的方法让我们可以注册/注销自己的 Filter 组件.

过滤器 Filter - DirectShow 的基本功能单元

DirectShow 中 Filter 是最基本的业务功能单元, 每个 Filter 理应能够独立完成一项工作. 而在 Filter 之下的 Pin 等组件则往往只能实现一个业务功能拆解后的某一项具体工作. Filter 组件通常以 COM DLL 组件的形式出现, 因此实现一个规范 Filter 组件的基础就是其满足 COM 组件的各项规范.

每个 Filter 理应有自己的”引脚” Pin 成员, Filter 根据其在 FilterGraph 中的位置不同可以分为三大类:

  1. Source Filter : 这类 Filter 提供数据源输入功能, 它们有至少一个输出 Pin;
  2. Transform Filter : 这类 Filter 提供数据加工的功能, 它们有至少一个输入 Pin 和至少一个输出 Pin;
  3. Rendering Filter : 这类 Filter 提供数据渲染输出的功能, 它们有至少一个输入 Pin;

这个分类并非指代某种具体的静态的 Filter 派生类, 这些区别仅仅体现在 Filter 的工作状态. 我们完全可以为一个 Source Filter 加入一个数据输入的 Pin. 当然这个改动后的 Filter 还能否正常工作自然需要开发者自己保证了.

此外, 每个 Filter 类型都有一个属于自己的 CLSID 作为唯一的标识, 通常这个 CLSID 也同时作为 COM 组件的 GUID.

CLSID, IID, GUID 这些类型本质上都是 GUID. 详见 “Win32 式” 命名规则 .

引脚 Pin - Filter 间的交流渠道

Pin 是一个 Filter 中最重要的成员之一, 绝大多数情况下 Pin 也需要依附于 Filter 才能进行工作. Pin 负责两个 Filter 之间的交互, 它们的工作围绕着传输数据这个最终目标展开, 其中的步骤包括:

  1. 双方检查对方所支持的媒体格式, 检查是否有共同支持的类型;
  2. 双方从共同支持的格式类型中协商后续传输将采用的格式;
  3. 建立连接, 开始传输数据;
  4. 其中一方关闭连接, 停止传输数据;

这个过程 DirectShow SDK 中提供了一套默认的方案. 根据上下游双方实现不同, 也可以自行决定细节流程. 但大多数应用都直接采用了默认实现.

Pin 在传输时会将数据打包成多个 MediaSample 媒体数据样本进行传输. MediaSample 内部抹除了数据本身的特征只保留了纯粹的字节数据, 同时将媒体信息保存在其他的字段中. Pin 通过这种方式以应对不同媒体数据类型, 这里不再展开.

可以简单把 MediaSample 类比为 FFMpeg 中的 AVFrame, 但前者更抽象. 理论上 MediaSample 可以传输所有类型的数据, 不只是音视频. 从更宏观的角度来看, 整个 DirectShow 的设计比起其他专注于某一类功能的 SDK 都更为抽象, 这是为了应对更多不同场景和设备考虑的.


实现过程

我们现在已经知道 DirectShow 提供了通用的接口供软硬件实现自己的 Filter, 基于 DirectShow 的应用程序则通过控制各 Filter 来控制各软硬件设备. 因此我们的目标就是实现一个 Source Filter 让其能够像其他物理摄像头所对应的 Filter 那样能够通过自己的输出 Pin 稳定向外传输视频流. 这样下游的 Filter 就可以像调用摄像头 Filter 一样调用我们的虚拟摄像头 Filter.

并且为了让我们的 Filter 组件能够被其他应用程序调用, 我们需要将其登记到本地 Windows 的注册表中. 这其中既需要进行 COM 组件的注册也需要进行 DirectShow 组件的注册.

总体规划

整个工程分为两个部分, 首先是符合 COM 标准和 DirectShow 标准的部分. 这一部分的主体就是一个符合规范的 DirectShow Source Filter. 它需要能够在注册表内被顺利的注册/注销. 并且实现以下三个实现类:

  1. Filter 工厂类 : 其将作为 COM 接口 DllGetClassObject 的返回项提供给外部;
  2. Filter 类 : 这是虚拟摄像头的主体, 拥有一个 Output Pin 成员;
  3. Output Pin 类 : Filter 对象的成员, 用于跟下游 Input Pin 连接并传输数据;

其次是 DirectShow 标准之外的部分. 这一部分是用于将真实的数据传输至 Filter 组件中. 本质上就是实现一个进程间通信的功能, 我采用了共享内存的方式. 同时为了适配提供数据源的程序, 可以将操作共享内存的部分独立出来. 根据需要独立编译提供不同版本的静态库.

此外, 考虑到数据源和实际输出的数据格式可能有所差异, 使用 FFMpeg 的 avutil 模块和 swscale 模块做缩放功能. 考虑到有时候没有数据源, 使用 Windows 自带的 GDI Plus 库读取一个预先准备的图片做为没有视频源时的占位符图片.

理论上也可以直接使用 FFMpeg 做占位符图片的工作, 但那样牵扯到很多 FFMpeg 的动态库. 为了这一个小功能引入整个 FFMpeg 没有必要, 不如直接使用 OS 自带的工具.

程序总体架构设计图见下:

虚拟摄像头架构图

图中红色线框内部是我们需要实现的部分. 其中绿色部分是属于 COM 标准的, 黄色部分是属于 DirectShow 标准的, 红色部分是自定义实现的.

Step 1 : 注册 COM 组件与 DirectShow 组件

当用户通过 regsvr32 注册动态库时, 将调用 COM 导出函数中的 DllRegisterServer 函数, 注销时将调用 DllUnregisterServer 函数. 我们需要在这两个函数内注册/注销 COM 组件和 DirectShow 组件.

注册 COM 组件的过程, 需要我们自己将组件信息写入至注册表中; 注销时将注册时登记的内容全部删除即可. 这里我们通过 Win32 提供的 API 即可实现, 包括:

  • RegCreateKeyRegCreateKeyEx : 用于创建注册表项;
  • RegSetValueRegSetValueEx : 用于在注册表项中设置键值对;
  • RegCloseKey : 用于关闭注册表项;
  • RegDeleteTree : 用于递归删除一个注册表项及其下属子项;

我们需要登记的信息以及注册表项结构包括 :

1
2
3
4
5
6
7
8
- 注册表
- HKEY_CLASSES_ROOT
- CLSID
- 组件的 CLSID <-------------------- 从这里开始是我们需要登记的
REG_SZ : (Default) = 组件的友好名称
- InprocServer32
REG_SZ : (Default) = 动态库完整路径
REG_SZ : ThreadingModel = Both

注册/注销 DirectShow 组件的过程则更简单一些, DirectShow 提供了相关的接口. 基本步骤如下:

  1. 使用 CoCreateInstance 构造一个 FilterMapper 对象. 这里我们需要通过 FilterMapper 组件的 CLSID CLSID_FilterMapper2 和具体需要用到接口类型的 IID IID_IFilterMapper2 ;
  2. 通过 IFilterMapper2::RegisterFilter 注册我们的 Filter, 这里需要指定我们的 Filter 类型. 这个类型并非上文提到的三种 Filter, 而是 Filter 负责的具体工作类型. 因为我们需要模拟一个视频输入设备, 所以需要指定类型为 CLSID_VideoInputDeviceCategory ;
  3. 通过 IFilterMapper2::UnregisterFilter 注销我们的 Filter;

当通过上述两个方法注册和注销 Filter 时, DirectShow 将自动帮我们将一些信息登记至注册表 :

1
2
3
4
5
6
7
8
9
- 注册表
- HKEY_CLASSES_ROOT
- CLSID
- Filter 类型 CLSID (CLSID_VideoInputDeviceCategory)
- Instance
- 组件的 CLSID <-------------------- 从这里开始是 DirectShow 帮我们登记的
REG_SZ : CLSID = 组件的 CLSID
REG_BINARY : Filter Data = 组件的类型参数 (二进制化)
REG_SZ : FriendlyName = 组件的友好名称

上例中的 Filter Data 是在注册 Filter 时的一个很重要的参数, 即 REGFILTER2 结构体. 它描述了一个 Filter 的基本信息, 包括其所能提供的 Pin 类型以及 Pin 的默认媒体类型. 示例见下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static const REGFILTER2 REGISTER_FILTER = {  // filter register info
1, // version, union's type, 1 for REGFILTERPINS
MERIT_DO_NOT_USE, // merit , auto connect priority
{ // union , pins
/*
** About filter's pins (next two members) :
** for Source Filter, it has 1 pin least for output;
** for Transform Filter, it has 1 pin least for output and other 1 pin least for input;
** for Rendering Filter, it has 1 pin least for input;
** there is no exception.
*/
1, // pin count
new REGFILTERPINS { // pin info
const_cast<LPWSTR>(L"Output"), // pin name
FALSE, // is input pin
TRUE, // is output pin
FALSE, // can be zero instance
FALSE, // can be many instance
&GUID_NULL, // class id of the filter who connects in a filter graph
nullptr, // name of the pin who connects in a filter graph
1, // media type count
new REGPINTYPES { // media type info (necessary part when register)
&MEDIATYPE_Video, // media type
&MEDIASUBTYPE_I420 // media sub type
}
}
}
};

Step 2 : 实现 Filter 工厂并对外提供工厂对象

所有的 DirectShow 对象都继承自 IUnknow 接口或 INonDelegatingUnknown 接口. 当 COM 组件外部调用 DllGetClassObject 获取组件内部的对象时, 实际上是在查询该对象对应的 IID_IClassFactory 接口, 也就是它的构造工厂. 再通过工厂对象构造实际的对象. 因此需要为我们的 Filter 定制一个构造工厂 FilterFactory.

所有 DirectShow 对象的构造工厂都继承自 IClassFactory 接口. 工厂的实现细节很简单, 它最大的作用就是通过 IClassFactory::CreateInstance 方法构造具体的对象. 同时若通过 IUnknown::QueryInterface 查询它所拥有的接口, 我们理应只在外部查询 IID_IClassFactoryIID_IUnknown 时返回自身.

构造工厂类的声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class VirtualCameraFactory : public IClassFactory {
public:
VirtualCameraFactory(const GUID& class_id);

public: // IUnknown
virtual HRESULT __stdcall QueryInterface(_In_ const GUID& interface_id,
_Out_ void** object ) override;
virtual ULONG __stdcall AddRef(void) override;
virtual ULONG __stdcall Release(void) override;

public: // IClassFactory
virtual HRESULT __stdcall CreateInstance(_In_ IUnknown* parent ,
_In_ const GUID& interface_id,
_Out_ void** object ) override;
virtual HRESULT __stdcall LockServer(BOOL lock) override;

private:
std::atomic<ULONG> reference_count_;
const GUID CLASS_ID_;
};

关于 IUnknown::QueryInterface 查询接口的逻辑以及 IUnknowINonDelegatingUnknown 的区别详见 COM 中的接口查询功能设计 .

Step 3 : 实现 Source Filter

基于 IBaseFilter 接口实现

所有的 Filter 都继承自 IBaseFilter 接口, IBaseFilter 最终继承自 IUnknow 接口. 我们共计需要实现 15 个纯虚函数, 包括:

  • 用于查询 Filter 信息的 : IBaseFilter::QueryFilterInfoIBaseFilter::QueryVendorInfo ;
  • 用于查询 Pin 信息的 : IBaseFilter::EnumPinsIBaseFilter::FindPin ;
  • 用于与 FilterGraph 连接的 : IBaseFilter::JoinFilterGraph ;
  • 用于控制 Filter 状态的 : IMediaFilter::Stop , IMediaFilter::Pause , IMediaFilter::RunIMediaFilter::GetState ;
  • 用于同步的 : IMediaFilter::SetSyncSourceIMediaFilter::GetSyncSource ;
  • 用于查询 CLSID 的 : IPersist::GetClassID ;
  • 用于查询所支持接口的 : IUnknown::QueryInterface ;
  • 用于控制对象引用计数的 : IUnknown::AddRefIUnknown::Release ;

理论上将每个函数都实现一遍可以做到最精细地控制, 不过这无疑是个疯狂的想法. 好在 DirectShow SDK 中提供了一套专门用于开发 Filter 的基类, 这些基类属于上文提到已被移除的 baseclasses 库. 因此我们需要自己编译成静态库再使用.

基于 CSource 基类实现

baseclasses 库提供了 CSource 基类, 通过对它派生可以方便地实现一个 Filter. CSource 本身帮我们实现了和具体业务无关绝大多数的接口, 我们只要在内部构造自己的 CSourceStream 派生类成员即可. CSourceStreambaseclasses 库提供的一种 Pin 基类, 通常与 CSource 一起使用. 当我们构造 CSourceStream 派生类对象时, 将自身指定为其 parent 对象, 二者内部将会自动帮我们处理向外部提供 Output Pin 的工作.

此外, 我们还应该在 CSourceINonDelegatingUnknown::NonDelegatingQueryInterface 接口实现内向外部提供 Filter 支持的媒体属性. 具体而言是 IID_IAMStreamConfig 接口和 IID_IKsPropertySet 接口. 下游 Filter 将会在 Pin 连接协商期间通过 IUnknown::QueryInterface 查询相关属性. 该方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HRESULT __stdcall
VirtualCameraFilter::NonDelegatingQueryInterface(_In_ const GUID& interface_id,
_Out_ void** object ) {
std::unique_lock<std::recursive_mutex> locker(mutex_);

if (!object) {
return E_POINTER;
}

if (IID_IAMStreamConfig == interface_id ||
IID_IKsPropertySet == interface_id) {
return stream_->QueryInterface(interface_id, object); // stream_ 即 output pin
}

return __super::NonDelegatingQueryInterface(interface_id, object);
}

我在这里将查询接口工作转发给了 Pin. 这是因为使用 CSource + CSourceStream 进行开发相比于使用 IBaseFilter + IPin 进行开发, 前者的工作重心已经从 Filter 本身转移至 Pin 中了. 因此我直接把相关属性的管理转移到 Pin 的内部. 但外部得到的接口类依然是 IBaseFilter , 所以查询工作还是会从 Filter 开始.

严格来说这是不符合 DirectShow 设计规范的, INonDelegatingUnknown::NonDelegatingQueryInterface 中理应只检查自身能直接控制到的接口, 即函数签名的 “非委托” 涵义. 至于为什么外部调用的是 IUnknown::QueryInterface 而我们的实现方法却是 INonDelegatingUnknown::NonDelegatingQueryInterface , 详见 COM 中的接口查询功能设计 .

Step 4 : 实现 Output Pin

基于 IPin 接口实现

所有的 Pin 最终都继承自 IPin 接口, IPin 则继承自 IUnknow 接口. 我们共计需要实现 16 个纯虚函数, 包括:

  • 用于 Pin 之间连接的 : IPin::Connect , IPin::ReceiveConnection , IPin::Disconnect , IPin::ConnectedTo ;
  • 用于下游向上游查询 Pin 状态的 : IPin::QueryPinInfo , IPin::QueryDirection , IPin::QueryId , IPin::QueryAccept , IPin::EnumMediaTypes , IPin::QueryInternalConnections ;
  • 用于上游向下游控制数据流传输状态的 : IPin::EndOfStream , IPin::BeginFlush , IPin::EndFlush , IPin::NewSegment ;
  • 用于查询所支持接口的 : IUnknown::QueryInterface ;
  • 用于控制对象引用计数的 : IUnknown::AddRefIUnknown::Release ;

直接基于 IPin 来实现 Pin 的难度是很大的, 不过在上文中我们已经确定了基于 CSourceStream 来实现自己的 Pin 而非直接基于 IPin .

基于 CSourceStream 基类实现

CSourceStream 是个多继承实现的类. 它首先直接继承自 CBaseOutputPin , 间接继承自 CBasePin , 最终继承自 IPin. CBaseOutputPin 帮助我们处理了一个 Output Pin 的需要实现的基本功能. 其次 CSourceStream 又继承自 CAMThread , 后者是 ActiveMovie 时代留下来的一个线程实现类, 前者用其来进行循环向外 “推流”.

CSource 的基础上, 我们依然需要实现以下接口 :

  • CBasePin::GetMediaType : 用于下游遍历支持的媒体参数类型;
  • CBasePin::CheckMediaType : 用于上下游协商具体的媒体类型;
  • CBasePin::SetMediaType : 用于下游设置最终采用的具体媒体参数类型;
  • CBaseOutputPin::DecideBufferSize : 用于上下游协商缓冲区的尺寸;
  • CSourceStream::FillBuffer : 用于向下游传输一个新的 MediaSample 媒体样本;

此外, 还有 CSourceStream::OnThreadCreate , CSourceStream::OnThreadDestroy , CSourceStream::OnThreadStartPlay 三个可选实现的回调, 用于在传输线程开关时进行一些初始化或资源回收工作.

这几个函数中有一部分是用于上下游 Pin 进行协商的, Pin 之间的协商详细过程详见 关于 Pin 连接协商过程与选择分辨率功能实现方式 . 简单来说就是下游 Pin 将双方所支持类型的交集逐个尝试协商. 上游 Pin 则在下游 Pin 每次协商操作时将目标类型调整为自己当前状态所能接收的最接近类型, 类似一种 “讨价还价” 的过程. 以最简单的协商缓冲区尺寸 CBaseOutputPin::DecideBufferSize 为例, 其实现见下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HRESULT VirtualCameraPin::DecideBufferSize(_In_    IMemAllocator*        allocator ,
_Inout_ ALLOCATOR_PROPERTIES* properties) {
std::unique_lock<std::recursive_mutex> locker(filter_->mutex());

auto* header = reinterpret_cast<VIDEOINFOHEADER*>(m_mt.Format());
properties->cbBuffer = header->bmiHeader.biSizeImage;
properties->cBuffers = 1;

ALLOCATOR_PROPERTIES real_properties;
if (FAILED(allocator->SetProperties(properties, &real_properties))) { return E_FAIL; }
if (real_properties.cbBuffer < properties->cbBuffer) {
*properties = real_properties;
return E_FAIL;
}

return S_OK;
}

这里外部将参数 properties 传入, 同时在函数执行完毕后会在外部进行检查. 我们则将其调整为我们所能接收的最接近属性 real_properties . 但如果我们认为自己已经知道了当前情况下的最优解, 也可以不做修改, 即不做任何妥协. 此时下游 Pin 将只能选择接收或放弃连接.

Step 5 : 向外部提供媒体信息查询接口

在 Filter 的实现小结中提到过, 我们在 Filter 向外部提供媒体信息查询接口. 虽然在 Pin 的连接过程中双方可以互相查询对方支持的媒体信息, 但这是 DirectShow 内部处理的细节, 用户是无法干预的. 可用户往往需要根据摄像头所支持的媒体参数自行决定以哪种方式打开摄像头, 因此这个功能是必要的. 在实际测试中, 一个 CLSID_VideoInputDeviceCategory 类型的 Source Filter 若不提供这些接口, 绝大部分应用是无法正常调用它的.

外部进程查询设备信息

我们需要实现的接口类有两个, 首先是 IKsPropertySet 接口, 它用于描述一个 IUnknow 对象的属性. 它的纯虚函数包括:

  • IKsPropertySet::QuerySupported : 用于查询该对象所支持的属性;
  • IKsPropertySet::Get : 用于查询该对象的属性;
  • IKsPropertySet::Set : 用于设置该对象的属性;

用户可以通过检查 Filter 是否拥有 AMPROPSETID_Pin 属性来判断这是不是一个可用的设备, 该属性描述设备引脚的所能提供的数据. 因此当我们的 IKsPropertySet::QuerySupported 被调用时, 若当前检查的属性类型为 AMPROPSETID_Pin , 我们需要将返回项置为 KSPROPERTY_SUPPORT_GET 表示支持该属性.

接下来, 用户则会通过使用 AMPROPSETID_Pin 作为 IKsPropertySet::Get 的参数查询该 Filter 的具体功能. 对于一个 CLSID_VideoInputDeviceCategory 视频输入设备而言, 它的 Source Filter 若有以下两种属性其一则代表它可以提供视频流输入:

  1. PIN_CATEGORY_CAPTURE : 此属性代表该 Source Filter 可以采集视频流;
  2. PIN_CATEGORY_PREVIEW : 此属性代表该 Source Filter 可以采集预览视频流;

实际测试得知, 基本没有用户会关心 PIN_CATEGORY_PREVIEW 属性, 绝大多数情况下都是直接使用支持 PIN_CATEGORY_CAPTURE 的 Filter. 因此我们需要在用户查询 AMPROPSETID_Pin 属性值时将 PIN_CATEGORY_CAPTURE 赋值给返回项, 表示我们可用采集视频流.

对于 IKsPropertySet::Set 我们的设备不接受用户进行任何设置, 因此在所有情况下都返回 E_NOTIMPL 即可.

IKsPropertySet::Get 函数为例, 我的实现如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
HRESULT __stdcall VirtualCameraPin::Get(_In_  const GUID& property_set_id   ,
_In_ DWORD property_id ,
_In_ void* instance_data ,
_In_ DWORD instance_size ,
_Out_ void* property_data ,
_In_ DWORD property_size ,
_Out_ DWORD* property_real_size) {
std::unique_lock<std::recursive_mutex> locker(filter_->mutex());

if (AMPROPSETID_Pin != property_set_id) { return E_PROP_SET_UNSUPPORTED; }
if (AMPROPERTY_PIN_CATEGORY != property_id) { return E_PROP_ID_UNSUPPORTED; }
if (sizeof(GUID) > property_size) { return E_UNEXPECTED; }
if (nullptr == property_data) { return E_POINTER; }
if (nullptr == property_real_size) { return E_POINTER; }

*property_real_size = sizeof(GUID);
*(GUID*)property_data = PIN_CATEGORY_CAPTURE;
return S_OK;
}

我这里的实现类是上文中的 Pin, 因为我直接让 Pin 多继承自 CSourceStream 的所有属性查询接口.

外部进程查询媒体信息

到这一步, 用户已经知道我们的设备不仅是视频输入设备, 并且是个确实可用可采集并为其提供视频流的设备了. 现在他还需要知道我们所提供能视频流的具体媒体格式. 此时需要用到第二个接口类 IAMStreamConfig , 它的纯虚函数包括:

  • IAMStreamConfig::SetFormat : 用于设置当前使用的媒体格式;
  • IAMStreamConfig::GetFormat : 用于获取当前使用的媒体格式;
  • IAMStreamConfig::GetNumberOfCapabilities : 用于获取该设备所支持媒体格式的总数;
  • IAMStreamConfig::GetStreamCaps : 用于遍历该设备所支持的所有媒体格式;

媒体格式被描述在结构体 AM_MEDIA_TYPE 中. 按照目前的实现, 我们的虚拟摄像头只有分辨率是支持多种类型的, 因此我们所支持的媒体格式总数就是所支持分辨率类型的总数. 若还有其他参数支持多种类型, 则需要将他们排列组合. 目前的 IAMStreamConfig::GetNumberOfCapabilities 实现如下 :

1
2
3
4
5
6
7
HRESULT __stdcall VirtualCameraPin::GetNumberOfCapabilities(_Out_ int* count, _Out_ int* size) {
std::unique_lock<std::recursive_mutex> locker(filter_->mutex());

*count = filter_->supportedResolutionCount();
*size = sizeof(VIDEO_STREAM_CONFIG_CAPS);
return S_OK;
}

当用户遍历我们所支持的格式后, 他可以选择一个具体的分辨率来打开我们的摄像头, 此时他就会调用 IAMStreamConfig::SetFormat . 至此, 用户对于打开摄像头前的准备工作都已经做完了. 接下来就是 DirectShow 内部的工作, 包括上下游 Pin 的协商连接和传输数据.

Step 6 : 使用 Pin 对外输出视频帧

对于直接基于 IPin 接口派生而来的 Pin 实现类, 需要通过实现以下几个方法控制流数据传输的状态 :

  • IPin::EndOfStream : 表示视频流的终止;
  • IPin::BeginFlush : 表示数据开始刷新;
  • IPin::EndFlush : 表示数据停止刷新;
  • IPin::NewSegment : 用于传递当前样本所代表视频片段的时间信息;

上文说过, Pin 之间将数据包装在 MediaSample 媒体数据样本中进行传递, 具体到实现上则是接口类 IMediaSample . 而媒体样本本身的构造和内存分配则是通过一个内存分配器接口 IMemAllocator 实现. 分配器是 Pin 的成员, 而具体将采用上游 Pin 的分配器还是下游 Pin 的分配器, 则是在双方协商期间确定的. 大体上来说, 若下游有一个实现且满足当前需求的分配器则优先采用下游的, 否则则采用上游的. 若上游的分配器也不满足当前需求或者根本没有分配器, 则其必须立即构造一个可用的分配器, 否则将导致协商失败.

对于基于 CSourceStream 基类派生而来的 Pin 实现类, 绝大部分 传输状态的控制和媒体样本构造等工作基类已经帮我们实现了. 我们需要做的是通过实现 CSourceStream::FillBuffer 接口填充数据, 实现见下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
HRESULT VirtualCameraPin::FillBuffer(_In_ IMediaSample* sample) {
std::unique_lock<std::recursive_mutex> locker(filter_->mutex());

uint8_t* data = nullptr;
size_t size = sample->GetSize();
if (FAILED(sample->GetPointer(&data))) {
return E_FAIL;
}

auto beg_time = std::chrono::high_resolution_clock::now();
auto dur_time = std::chrono::nanoseconds(FpsToNspf(filter_->getFps()));
auto end_time = beg_time + dur_time;

REFERENCE_TIME next_timestamp = timestamp_ + dur_time.count() / 100;
sample->SetTime(&timestamp_, &next_timestamp);
timestamp_ = next_timestamp;

filter_->outputFrame(data, size); // 这里填充视频帧数据

if (std::chrono::high_resolution_clock::now() < end_time) {
std::this_thread::sleep_until(end_time); // 注意这里
}

return S_OK;
}

在这里 Pin 将视频帧数据填充至 MediaSample 内, 但具体视频帧的来源则是我们的 Filter. 因为在这里我让 Pin 负责与外部沟通, 让 Filter 负责具体的内部工作.

你可能有注意到我在实现里加入了线程等待的行为, 这是在模拟当前流的 FPS. 理论上基类应该根据当前 FPS 来按照恰当的频率来获取视频帧, 但实际上 CSourceStream 没有这么做. 具体细节详见 3. 输出 IMediaSample 的回调频率需要自行控制 .

Step 7 : 在 Filter 中获取视频帧

现在我们已经能够与外界正常建立数据传输通道并且稳定地传输数据了, 但具体的数据源从哪来还没解决, 这就是 Filter 的工作. 在上一节的代码中我们使用了 Filter 的 outputFrame 方法来获取当前的视频帧, 不妨先来看看它的实现 :

1
2
3
4
5
6
7
8
9
10
void VirtualCameraFilter::outputFrame(uint8_t* data, size_t size) {
std::unique_lock<std::recursive_mutex> locker(mutex_);

const auto frame_size = std::min(size, getFrameSize());

if (loadStreamFrame(data, frame_size)) { return; }
if (loadPlaceholder(data, frame_size)) { return; }

memset(data, 127, size); // default with a gray frame
}

在这里我们返回的视频帧有 3 种类型 :

  1. 若当前有视频帧则返回当前视频帧;
  2. 若当前没有视频帧, 则返回当前帧大小的占位符图片;
  3. 若前两者都没有, 则返回一个纯色画面;

接下来我们逐个讨论.

真实视频帧

在介绍程序整体结构时说过, 我们的真实视频帧来源自共享内存. 而在这里我们需要做的就是从共享内存中读取数据并返回. 其中对一些细节需要加以判断. 例如当共享内存的发送端不存在时, 我们需要返回错误以表示当前没有视频帧. 亦或是当输入的视频帧分辨率或像素格式与我们将要输出的参数不一致时, 我们需要进行转换.

此外, 考虑到帧的输入与输出并不是同步的, 我额外缓存了上一个视频帧的画面. 当遇到输入输出的时间差间隙时, 则继续输出上一帧画面, 一面视频中偶尔闪现占位符图片. 同时只缓存一帧也保证了输出比输入至多延迟一个 TPF 时长. VirtualCameraFilter::loadStreamFrame 的实现如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
inline bool VirtualCameraFilter::loadStreamFrame(uint8_t* data, size_t size) {
std::unique_lock<std::recursive_mutex> locker(mutex_);
if (!memory_reader_.opened() && !memory_reader_.open()) {
return false;
}

// If shared memory invaild, it means that the stream is disabled or closed.

if (!memory_reader_.vaild()) {
prev_frame_.reset();
return false;
}

FrameMemory* next_frame = memory_reader_.read(); // 在这里读视频帧
if (next_frame) { // 在这里转换格式
FrameMemory* scaled_frame = ScaleFrame(next_frame, getCurrentResolution(), getPixelFormat());
delete next_frame;
if (scaled_frame) {
memmove(data, scaled_frame->data, size);
prev_frame_.reset(scaled_frame);
return true;
}
}

// If stream is still open but there are no more frame now,
// it probably means that there is a problem with the writer that delay the encoding.
// Just wait at the last frame until stream resumes working or closes.

if (prev_frame_) { // 一帧缓存
memmove(data, prev_frame_->data, size); // 在这里写数据
return true;
}

return false;
}

其中 memory_reader_ 成员就是操作共享内存数据的对象, 后面会详谈. ScaleFrame 就是转换格式的方法, 这里我用了 FFMpeg 的 sws_scale 功能, 不再展开讨论.

占位符图片

占位符图片在当前没有视频源输入或是读取输入的视频帧失败时, 将作为外部进程调用摄像头所看到的画面. 原理也很简单, 提前准备好一张图片, 并将其转换至所有我们至此的媒体格式保存起来. 当需要输出占位符图片的时候则将对应格式的图片拷贝出去.

上文中提到我在这里是使用 GDI Plus 实现的, 它有一个缺点就是不支持 YUV 像素格式. 因此对于 YUV 格式的视频, 我们只能先以 RGB 格式读取图片, 再手动将其转化为 YUV 格式. VirtualCameraFilter::loadPlaceholder 的部分实现如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
inline bool VirtualCameraFilter::loadPlaceholder(uint8_t* data, size_t size) {
std::unique_lock<std::recursive_mutex> locker(mutex_);
if (resolution_placeholder_map_.empty()) {
// Find placeholder file path.

......

// Init placeholder for each supported resolution.

......

do {
// Load file as rgb24 default resolution. (gdi plus doesn't support yuv)

......

for (const auto& resolution : resolution_deque_) {
// scale resolution

Gdiplus::Bitmap sacled_bitmap(resolution.width, resolution.height, PixelFormat24bppRGB);
Gdiplus::Rect rect(0, 0, resolution.width, resolution.height);
Gdiplus::Graphics graphics(&sacled_bitmap);
graphics.DrawImage(&source_bitmap, rect);
if (Gdiplus::Status::Ok != graphics.GetLastStatus()) {
continue;
}

// scale pixel format

const size_t src_size = CalculateFrameSize(PIXEL_FORMAT::RGB24, resolution);
const size_t dst_size = CalculateFrameSize(format_.pixel_format, resolution);
std::vector<uint8_t> placeholder(dst_size, 0);
Gdiplus::BitmapData bitmap_data;
if (Gdiplus::Status::Ok != sacled_bitmap.LockBits(&rect, Gdiplus::ImageLockModeRead,
PixelFormat24bppRGB, &bitmap_data)) {
continue;
}
// 在这里转换像素格式
if (ScalePixelFormatFromRgb24(resolution.width, resolution.height,
static_cast<uint8_t*>(bitmap_data.Scan0), src_size,
placeholder.data(), dst_size, format_.pixel_format)) {
// save placeholder data
resolution_placeholder_map_.insert({ resolution, std::move(placeholder) });
}

sacled_bitmap.UnlockBits(&bitmap_data);
}
} while (false);

// Must release all memory about gdi plus before calling Gdiplus::GdiplusShutdown.
Gdiplus::GdiplusShutdown(gdi_token);
}

// Load placeholder data for the current resolution.

auto itor = resolution_placeholder_map_.find(getCurrentResolution());
if (itor == resolution_placeholder_map_.end()) { return false; }
memmove(data, itor->second.data(), size); // 在这里写数据
return true;
}

其中 ScalePixelFormatFromRgb24 就是负责针对不同像素格式进行转换的方法, 具体算法都是从网上找来的, 不展开讨论.

纯色缺省图片

在最差的情况下, 为了保证视频流稳定输出, 我们之间将数据填充为一个灰色的帧. 由于当前默认采用 I420 像素格式, 因此我将其全部填充为 127. 这是最简单的一种情况.

Step 8 : 使用共享内存传递视频帧

共享内存用于连接提供视频源的进程与虚拟摄像头进程. 考虑到前者与后者之间编译环境的差异, 共享内存的操作被放在一个独立的静态库工程中. 当我们需要在不同的程序里写入视频源, 就在当前环境下编译一个对应的静态库即可.

Windows 平台操作共享内存

Win32 提供了操作共享内存的一系列 API, 它们都在头文件 memoryapi.h 里, 我们需要用到的有:

  • CreateFileMapping : 用于创建文件映射, 当我们不指定具体文件的句柄时则表示创建一个共享内存;
  • MapViewOfFile : 用于将文件/共享内存中的数据映射到当前进程的内存中;
  • UnmapViewOfFile : 用于关闭内存映射;
  • CloseHandle : 用于关闭句柄;

当建立好映射后, 我们对进程内映射内存的操作将同步到共享内存中. 需要注意的是若建立映射时给的权限不足, 当某个进程出现越权操作则会被 OS 直接杀死. 例如只设置了读权限却对内存进行任何写入操作.

具体设计

共享内存约定以”一写多读”的形式工作, 其中保存着视频输入源的状态, 而没有虚拟摄像头的状态. 也就是说虚拟摄像头可以检查当前是否有视频输入源, 而视频输入源却无法感知当前是否有开启的虚拟摄像头. 这是因为我们并不知道调用虚拟摄像头的进程程序是否足够健壮, 或许它没有办法正常退出并且将虚拟摄像头状态复位.

当然同理我们也不能完全确定视频输入源的进程程序是否能够将状态复位, 但因为只有它能够提供视频源的状态且这个状态是必须的, 因此我们只能选择信任输入方.

这么设计的代价便是视频输入源必须在可以写入视频帧时立即写入, 无论当前是否有开启的虚拟摄像头.

共享内存的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// do not use size_t

static constexpr uint64_t LINE_COUNT = 4;
static constexpr uint64_t BUFFER_SIZE = 1920 * 1080 * 24 / 8;

struct FrameMemory {
uint32_t width = 0;
uint32_t height = 0;
int32_t pixel_format = -1; // AVPixelFormat
uint8_t data[BUFFER_SIZE] = {0}; // frame data buffer
int32_t line_size[LINE_COUNT] = {0};
};

struct SharedMemory {
bool vaild = false;
bool readable = false;
FrameMemory frame = {};
};

我把数据缓冲区的尺寸设置为所支持媒体格式中一帧画面的最大尺寸, 即 RGB24 1080p 的视频帧尺寸. 共享内存中保存着最新一个视频帧的数据. 之所以只保存一帧, 是考虑到可能有多个摄像头在同时读取数据. 若使用队列结构则出队操作会遇到问题, 因为每个摄像头在读取数据后都会令视频帧出队. 因此我最后抛弃了这个方案, 只保留在共享内存中保留一帧数据. 且只有写入方有权限更新数据, 所有的虚拟摄像头都只能通过拷贝数据获取视频帧.

若你决定缓存多个帧, 则还需要另外实现一个纯栈内存的队列结构.

注意事项

进行读写操作时需要注意, 像 AVFrame 结构内的每一页数据内存并不一定完全是有效数据. 当我们进行数据拷贝时需要注意略过每一页末尾的无效内存段, 只拷贝视频帧实际有效的数据.

以拷贝一个 I420 像素格式的 AVFrame 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// em::FrameMemory* memory;
// AVFrame* frame;
switch (frame->format) {
case AV_PIX_FMT_YUV420P: {
uint8_t* data = memory->data;
memmove(data, frame->data[0], frame->linesize[0] * frame->height);

data += frame->linesize[0] * frame->height;
memmove(data, frame->data[1], frame->linesize[1] * frame->height / 2);

data += frame->linesize[1] * frame->height / 2;
memmove(data, frame->data[2], frame->linesize[2] * frame->height / 2);
break;
}
case AV_PIX_FMT_UYVY422:
......
break;
case AV_PIX_FMT_BGR24:
......
break;
default:
delete memory;
memory = nullptr;
break;
}

目前这一工作需要数据发送端自行完成, 后续待优化.

还有一点需要注意, 如果没有对共享内存做内存对齐处理的话, 就尽量不要针对整个结构体进行内存拷贝. 例如 64 位的输入端与 32 位的输出端之间若直接进行整个视频帧数据的拷贝则会出错. 不过这一点在我的实现中已经在共享内存读写静态库中处理了, 因此若不需要自己实现则不必担心.

流程回顾

虚拟摄像头一次工作的完整流程可分为 5 个阶段 :

  1. 外部进程获取设备 Filter 过程:
    该阶段中主要进行的是 COM 接口查询和 Filter, Pin 等对象构造工作, 成功与否之取决于组件内部的实现是否完善.

  2. 用户检查 Filter 与前期设置过程:
    该阶段与 DirectShow 内部没有太大关系, 是用于实际的用户检查设备是否是其需要的. 这一阶段是否成功主要取决于用户决定是否要打开设备. 同时在这一阶段用户将选择具体要打开设备的媒体格式.

  3. 上下游 Pin 协商连接过程:
    该阶段完全是 DirectShow 内部的事务, 与实际用户无关. 用户选择了若干个设备加入他的 FilterGraph 中, 其中包括我们的虚拟摄像头. 此时我们需要与下游 Filter 的 Pin 相连接, 中间包括了所有细节的协商过程. 这一阶段是否成功直接取决于上下游双方能否支持同样的格式, 但根本上是取决于用户是否合理地选择了两个可以适配的 Filter.

  4. 数据传输过程:
    该阶段工作是由 Filter 的内部实现完成的, 面向外部来说我们只需要稳定地传输视频帧即可. 能否提供正确的视频帧同样取决于内部实现. 此处最大的隐患有两点: 其一是视频源发送端的进程异常退出, 导致虚拟摄像头一直读取最后一帧播出; 其二是占位符图片被用户手动删除了, 进而没法正常显示. 该阶段的结束源自上下游任何一方终止流地传输, 当然我们的实现里虚拟摄像头是永远可以提供视频流的, 因此只取决于下游何时关闭流.

  5. 资源回收过程:
    当上下游 Pin 之间的连接被断开时, 用户已经不再需要我们这个设备了. 因此我们只需要按照自己的方式释放所有资源即可.

完整的流程图见下:

总流程图


细节重点

1. DirectShow 组件 32 位版本与 64 位版本的注册路径不同

如题, 不同的版本 DirectShow 组件在注册表中的注册位置不同. 同时当应用程序查询组件时默认也只能查询到该应用程序对应的版本. 因此同时提供 32 位版本与 64 位版本是必要的.

目前来看绝大多数视频会议类型的软件对外提供的都是 32 位版本, 但大部分开源软件如 PotPlayer 或 OBS 或者一些工具软件则取决于具体的编译版本.

目前我只编译测试了 32 位的组件, 后续考虑加入 64 位版本.

2. 关于 Pin 连接协商过程与选择媒体类型功能实现方式

如果你的 Pin 是基于 IPin 接口实现的话, 你可以直接掌控协商过程的所有细节, 因此无需关心这一节的内容. 这里主要讨论的是基于 DirectShow SDK 中 CSourceStream 基类实现的 Pin 在协商连接过程中需要注意的点.

以下内容比较复杂, 建议结合 DirectShow baseclasses 库源码一起阅读.

DirectShow SDK 默认的 Pin 协商过程

DirectShow 中两个 Pin 的连接过程从宏观的角度上看, 概括起来如下:

  1. FilterGraphManager 在输出 Pin 上调用 IPin::Connect 同时将输入 Pin 作为参数传入;
  2. 如果输出 Pin 接收连接, 则调用输入 Pin 上的 IPin::ReceiveConnection ;
  3. 如果输入 Pin 也接收连接, 则双方连接成功;

Pin 之间判断是否要接收这次连接则根据双方的具体实现. DirectShow SDK 本身提供了一套基础的协商流程, 概况起来如下:

  1. CBasePin::Connect 中进行一些合法性检查, 确认双方是可用的 Pin 时再调用输出方的 CBasePin::AgreeMediaType 协商媒体类型;
  2. CBasePin::AgreeMediaType 本身可以携带决定进行连接的媒体类型, 若参数的媒体类型已经把所有细节都指定好了, 则直接根据输入方是否接收这个类型决定协商是否成功. 但在默认实现中这里个媒体类型的所有细节都没有被指定, 因此这一步实际是直接忽略的;
  3. 2. 中提到的媒体类型是没有完全指定的, 则真正地协商过程在此处正式开始. 此时分别先通过调用双方的 CBasePin::EnumMediaTypes 检查双方是否有支持这个不完整的媒体类型, 若双都无法接收则协商失败. 若其中一方认为可以接受则会调用它的 CBasePin::TryMediaTypes 并传入另一方的指针, 让前者主导进行尝试连接.
  4. CBasePin::TryMediaTypes 中, 主动方将遍历自身所支持的媒体类型, 并通过 CBasePin::AttemptConnection 与被动方进行”试连接”.
  5. CBasePin::AttemptConnection 中, 主动方首先对自己当前状态进行连接前最后的检查. 确认自己当前可以以这种媒体类型进行连接后, 再调用被动方的 CBasePin::ReceiveConnection 要求对方尝试以这个媒体类型进行连接.
  6. CBasePin::ReceiveConnection 中, 最重要的部分是被动方调用 CBasePin::CheckMediaType 检查自身是否支持这个媒体类型. 而该函数是一个虚函数, 因此这是我们干预协商过程的唯一途径.

因为只要一方不支持一个不完整的媒体类型, 则大概率对方无论如何尝试连接都会失败的. 因此 3. 中的主动方绝大多数情况下都是输入 Pin , 因为它是首先被检查的.

唯一的例外就是一个 Pin 只接受一种完整的媒体类型, 因此对它进行检查时它直接将所有不一致的类型拒接了. 这种情况下才有可能出现”虽然不支持一个不完整的类型, 但却支持在这个不完整类型基础之上的完整类型”这一情况.

默认协商过程的潜在问题

首先, 默认协商过程直接略过了步骤 2. , 同时 CBasePin::AgreeMediaType 不是一个虚函数. 这让用户在外部指定的媒体类型无法在这一步中影响协商的过程.

其次, 在步骤 4. 中输入 Pin 逐一使用自身支持的媒体类型与输出 Pin 试连接. 因此只要某一个媒体类型是双方都能够接受的, 就被判定为协商结束连接成功.

上述两处设计导致了在默认协商过程中, 若能够连接成功, 则最终连接建立时采用的媒体类型不取决于用户自己选择的类型, 而取决于 上下游 Pin 所支持的媒体类型列表中, 第一个双方都支持的选项 . 如此一来如果上下游双方均采用默认实现, 则用户指定的媒体类型将不会生效.

在默认协商流程基础上允许用户选择媒体类型功能的实现方式 (解决方案)

CBasePin::CheckMediaType 中加以干预

好在整个默认协商流程中, 我们有一个干预的途径, 即 CBasePin::CheckMediaType 虚函数. 当我们重写这个函数时, 意味着我们将自行决定是否支持参数中的媒体类型. 因此假设我们在该函数中只在参数中的媒体类型与当前用户所选择的媒体类型相等时判断我们是支持的, 这样理应可以在协商时强制只认定用户所选择的类型. 进而实现我们的需求.

但这么做会带来新的隐患. 因为虽然在前文中我们一直强调这个函数在 Pin 协商过程中的作用, 但在 DirectShow 内部这个函数实际上是被用于检查一个 Pin 所支持的媒体类型, Pin 的协商只是它的一个使用场景. 因此一旦我们进行上面的处理, 在用户设置具体的格式之前, DirectShow 就会认定我们只支持一种媒体类型, 即我们内部保存的当前媒体类型在构造时的默认状态. 如此, 用户甚至不知道我们支持多种媒体类型, 就更不用谈选择了.

IAMStreamConfig::SetFormat 中加以干预

在上一个方案中, 我们着眼于缩小”试连接”过程中媒体类型检查的范围, 只检查当前选择的类型. 却导致了其他情况下 DirectShow 无法正常地检查我们支持的媒体类型. 因此缩小 CBasePin::CheckMediaType 时的检查范围是不可取的.

我们再回顾一次默认协商流程的问题 : 连接建立时采用的媒体类型取决于 上下游 Pin 所支持的媒体类型列表中, 第一个双方都支持的选项 . 因此我们可以尝试提高当前用户所选项被检查时的优先级, 而非直接限制只能检查当前用户所选项.

我们需要从 IAMStreamConfig::SetFormat 中下手, 因为这里是用户设置其所选媒体参数的地方. 由于目前我们的虚拟摄像头只有分辨率是可选的, 因此我在用户设置参数时, 调整了支持分辨率的队列顺序, 将用户所选的分辨率移动至队头. 这样遍历分辨率时该选项将被第一个遍历到. 大致的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

HRESULT __stdcall VirtualCameraPin::SetFormat(_In_ AM_MEDIA_TYPE* media_type) {

......

CMediaType media_type_object(*media_type);
if (CheckMediaType(&media_type_object) != S_OK) {
return E_FAIL;
}

auto* header = reinterpret_cast<VIDEOINFOHEADER*>(media_type->pbFormat);
Resolution resolution;
resolution.width = header->bmiHeader.biWidth;
resolution.height = header->bmiHeader.biHeight;
if (!filter_->setCurrentResolution(resolution)) { // 在这里将用户所选项转移至队头
return E_FAIL;
}

......

return S_OK;
}

如此一来, 用户所选的媒体类型将在协商时被率先遍历到. 此时只要用户所选的媒体类型是对方可接受的, 那么这个媒体类型就必定是第一个双方都支持的选项.

至此, 就实现了在遵循 DirectShow SDK 默认协商流程的基础上, 支持让用户自行决定媒体参数的功能了.

这里我的实现只涉及到分辨率, 因此调整一个队列即可. 若你的实现需要支持其他参数的调整, 只要根据实际情况将调整其他参数支持队列的顺序即可.

3. 输出 IMediaSample 的回调频率需要自行控制

与上一节同理, 如果你是基于 IPin 接口直接实现自己的 Pin, 你需要直接控制数据传输的频率. 这里讨论的也是基于 CSourceStream 基类实现的情况.

回调频率与视频流的 FPS 不符合问题分析

在我实现第一版虚拟摄像头后, 测试发现在 CSourceStream 向下游传输数据的函数 CSourceStream::FillBuffer 被调用的频率与当前视频流的 FPS 不一致, 前者远高于后者. 这一问题在阅读 DirectShow 源码并且分析 Source Filter 的设计思路后得以解决.

不同于开发上层应用, 在上层应用中若我们为某设备对象设置一个具体的媒体参数, 这个对象理应将该参数设置在硬件设备中. 如果设置成功, 则当我们调用该硬件时, 通常是相信这个设备将以我们设置的参数工作. 开发 Source Filter 时, 我们本身就在开发一个设备对象, 因此当上层应用将参数设置给我们时, 是否遵守这个参数则全靠我们内部的实现. 因此当视频流的 FPS 被设置为定值时, 理应由 Source Filter 处理数据传输的频率, 而非下游 Filter.

同样不同于实际的设备驱动, 当一个对应着一个物理设备的 Source Filter 对外宣称其支持某种媒体参数时. 通常实际反应着其对应的物理设备本身所能支持的参数. 因此一个物理设备的 Source Filter 以某种 FPS 传输视频流时, 通常情况下只需要等待设备采集出视频帧并忠实地向下游传输即可.

而对于我们的虚拟摄像头, 理论上是可以支持任何类型媒体参数的, 这仅仅只是内部的实现问题. 因此出现上述问题的原因很可能也是因为 CSourceStream 与下游 Filter 都没有对回调的频率进行限制.

CSourceStream 对数据发送的默认实现

前文提到, CSourceStream 本身提供了一个线程, CSourceStream::FillBuffer 仅仅只是填充数据的回调函数. 实际上这个函数在 CSourceStream::DoBufferProcessingLoop 线程函数中被循环调用, 这个函数的部分源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
HRESULT CSourceStream::DoBufferProcessingLoop(void) {
......
do {
while (!CheckRequest(&com)) {

IMediaSample *pSample;

HRESULT hr = GetDeliveryBuffer(&pSample,NULL,NULL,0);
if (FAILED(hr)) {
Sleep(1);
continue; // go round again. Perhaps the error will go away
// or the allocator is decommited & we will be asked to
// exit soon.
}

// Virtual function user will override.
hr = FillBuffer(pSample); // 在这里填充一个媒体数据样本

if (hr == S_OK) {
hr = Deliver(pSample);
pSample->Release();

// downstream filter returns S_FALSE if it wants us to
// stop or an error if it's reporting an error.
if(hr != S_OK)
{
DbgLog((LOG_TRACE, 2, TEXT("Deliver() returned %08x; stopping"), hr));
return S_OK;
}

} else if (hr == S_FALSE) {
......
return S_OK;
} else {
......
}

// all paths release the sample
}

// For all commands sent to us there must be a Reply call!

......
} while (com != CMD_STOP);

return S_FALSE;
}

这里的线程函数使用一个 while 循环持续以阻塞的方式调用 CSourceStream::FillBuffer , 循环条件里的 CSourceStream::CheckRequest 直接返回一个成员变量的状态, 在循环中的其余部分也没有发现任何会造成线程等待的语句. 因此我们可以断定 CSourceStream 本身没有针对视频流的 FPS 对回调频率进行限制.

大概率 CSourceStream 并没有想到我们将会用其实现一个虚拟摄像头这样的功能.

CSourceStream::FillBuffer 中自行控制回调频率

既然各方都不对回调频率进行限制, 那么我们只能自行处理了. 实现方法也很简单, 每一帧输出之后使线程等待一个视频帧的时间, 后再输出下一帧画面. 实现见下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
HRESULT VirtualCameraPin::OnThreadStartPlay() {  // 该回调在一个新视频流开始时调用
timestamp_ = 0; // 在这里重置时间戳
return NOERROR;
}

HRESULT VirtualCameraPin::FillBuffer(_In_ IMediaSample* sample) { // 该回调填充一个视频帧
std::unique_lock<std::recursive_mutex> locker(filter_->mutex());

uint8_t* data = nullptr;
size_t size = sample->GetSize();
if (FAILED(sample->GetPointer(&data))) {
return E_FAIL;
}

auto beg_time = std::chrono::high_resolution_clock::now(); // 当前帧开始时间
auto dur_time = std::chrono::nanoseconds(FpsToNspf(filter_->getFps())); // 当前帧持续时间
auto end_time = beg_time + dur_time; // 当前帧结束时间

REFERENCE_TIME next_timestamp = timestamp_ + dur_time.count() / 100; // 计算下一帧时间戳
sample->SetTime(&timestamp_, &next_timestamp); // 设置该帧的显示时间
timestamp_ = next_timestamp; // 时间戳累计

filter_->outputFrame(data, size);

if (std::chrono::high_resolution_clock::now() < end_time) {
std::this_thread::sleep_until(end_time); // 在这里使线程等待
}

return S_OK;
}

本身处理逻辑很简单, 只有一些细节需要注意. 首先是我们需要在媒体数据样本中设置该样本显示的时间, 这同样需要自行计算. 其次是 DirectShow 使用 100ns 作为粒度最小的时间单位, 我们需要先计算纳秒数再进行转换.


引申

“Win32 式”命名规则

在 Win32 时代, 微软曾使用了一套比较少见的命名规则. 这套规则以今天的眼光来看堪称集”百家之短”于一身, 如今也被它自己抛弃了. 这里总结了一小部分命名规则, 以免在阅读 Win32 代码时比较费解.

基础类型命名规则

所有基础类型, 或是在 Win32 中被频繁使用的结构体, 统一全部大写且中间不加下划线. 例如 :

  • HRESULT : COM 函数的返回值类型;
  • LRESULT : Win32 环境下进程/函数返回值类型;
  • VIDEOINFOHEADER : Video Info Header , 视频信息头;

一些类型别名

许多类型名其实表示的是同一种类型, 只是根据当前的语境, 使用的不同的名字以示区分. 例如 :

  • GUID : Globally Unique Identifier , 全局唯一标识符;
  • CLSID : 即 GUID , Class GUID , 全局唯一类型标识符;
  • IID : 即 GUID , Interface GUID , 全局唯一接口标识符;
  • FMTID : 即 GUID , Format GUID , 全局唯一格式标识符;
  • REFGUID : 即 const GUID& , Reference GUID , 全局唯一标识符引用;
  • REFCLSID : 即 const CLSID& , Reference Class GUID , 全局唯一类型标识符引用;
  • REFIID : 即 const IID& , Reference Interface GUID , 全局唯一接口标识符引用;
  • REFFMTID : 即 const FMTID& , Reference Format GUID , 全局唯一格式标识符引用;

变量命名规则

当类型名足以描述当前变量时, 使用类型名全小写, 构成 全大写类型名 全小写类型名变量; 的语法, 例如 :

  • CLSID clsid; : 一个类型标识符变量;
  • HANDLE handle; : 一个句柄;

部分缩写的含义

  • LP 开头的类型 : Low Pointer , 16 位机时代表示 2 字节长度的指针类型, 现表示指针类型. 如 :
    LPVOIDvoid* ,
    LPCLSIDCLSID* ;

  • REF 开头的类型 : Reference , 表示引用类型, 如 :
    REFVARIANTconst VARIANT& ,
    REFIIDconst IID& ;

  • C 开头的基础类型 : Const , 表示常量类型, 如 :
    LPCWSTRconst WCHAR* (L P C W STR, Low Pointer Const Wide String) ;

  • C 开头的类类型 : Class , 表示实现类(不一定完全实现), 如 :
    CBaseOutputPin 应理解为 ClassBaseOutputPin ;

  • I 开头的类型 : Interface , 表示接口类型(纯虚类), 如 :
    IBaseFilter 应理解为 InterfaceFilter ;

  • AM 的类类型 : 表示 ActiveMovie , 如 :
    IAMStreamConfig 应理解为 InterfaceActiveMovieStreamConfig 而非 I'am Stream Config ;
    但可能只缩写一个单词, 如 IAMovieSetup 应理解为 InterfaceActiveMovieSetup ;

一些特殊的类型

  • BOOL : 实际上是 int 而非 bool , 其用作一个三元的布尔值, 可表示 “真” , “假” 以及 “错误” ;
  • BYTE : 即 unsigned char 表示 1 个字节;
  • WORD : 即 unsigned short 表示 2 个字节;
  • DWORD : 即 unsigned long , 理解为 Double WORD 表示 4 个字节;

Win32 中的函数签名规范

返回状态

Win32 的函数规范保留了 C 语言时代的特征. 所有的函数返回值类型均是 LRESULTHRESULT , 其中后者是 COM 函数的返回值类型, 它们的区别在于操作是否成功的信息保存在最低位或最高位 (Low Result , High Result). 这两个类型并不携带具体的返回值, 而是返回该函数的处理状态. 以 HRESULT 为例, 其内存结构如下 :

  • 第 31 位 : 表示操作是否成功;
  • 第 30 位 - 第 16 位 : 表示设备代码, 用于表示返回状态的类型;
  • 第 15 位 - 第 0 位 : 表示返回代码, 用于表示返回的状态;

例如 :

  • S_OK : 操作成功;
  • S_FALSE : 操作成功, 但执行结果未达预期;
  • E_FAIL : 操作失败, 且没有任何其他信息;
  • E_UNEXPECTED : 操作失败, 遇到了无法预知的情况;

根据具体的语境, 可以使用携带信息更丰富的返回值:

  • E_NOTIMPL : 操作失败, 该成员函数未被实现;
  • E_NOINTERFACE : 操作失败, 没有这个接口;
  • E_INVALIDARG : 操作失败, 参数错误;
  • E_POINTER : 操作失败, 遇到无效指针;

错误处理

检查返回状态需要使用 FAILEDSUCCESSED 宏, 示例见下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if (SUCCESSED(DoSomeThing())) {
// go on
}

// ----- or -----

HRESULT hr;
hr = DoSomeThing();
if (FAILED(hr)) {
switch (hr) {
case S_FALSE:
// handle error
break;
case E_FAIL:
// handle error
break;
default:
break;
}
}

这同样是 C 语言时代留下来的习惯. 这是一种不同于高级语言异常处理的机制, 有人称其为错误处理. 即所有行为均需要返回一个状态值以表明操作成功性, 以提供上层检查并纠错. 与异常处理不同的是, 错误处理必须在错误出现的上一层立即处理错误, 错误无法继续向上传递, 且错误处理是基于开发者之间的约定而非语法规范.

参数项类型

函数的返回值被操作状态占用了, 那么外界需要返回一个变量时, 则需要通过参数列表传递指针. Win32 中定义了一系列空的宏用于函数签名中标识参数项的作用, 如 :

  • _In_ : 表示该参数是一个输入项;
  • _Out_ : 表示该参数是一个输出项;
  • _Inout_ : 表示该参数既是输入项也是输出项;
  • _In_opt_ : 表示该参数是一个可选输入项;

例如:

1
2
3
4
5
6
7
class VirtualCameraFilter : public CSource {
public:
VirtualCameraFilter(_In_ const GUID& class_id,
_Inout_opt_ IUnknown* owner ,
_Inout_ HRESULT* result );
......
};

COM 中的接口查询功能设计

在 OOP 的编程思想中, 一个对象通过实现多个接口类, 进而对外提供多个接口的功能. 当外部获取到该对象的静态类型指针/引用时, 则可以直接调用其基类接口的方法. 但如果外部只能获取到该对象的基类指针/引用, 则此时需要通过动态类型转换检查是否支持其他基类接口. 而动态类型转换则必须依赖于动态运行时环境, 这个运行时环境目前在许多高级语言或者一些大型的第三方库中都有提供. C# 与 Java 原生就支持动态类型, 现代 C++ 有标准的 RTTI 系统和一些第三方库提供的动态运行时, 比如 Qt 的元对象系统和 UE 的属性系统等.

而在 COM 编程思想盛行的年代, C++ 还没有引入 RTTI 系统, 因此上述通过动态类型转化使用接口的功能也无从谈起. 为此 COM 设计了一套不依赖于 RTTI 的接口查询功能. 在这套机制中, 一个 COM 组件所能提供的接口对象, 并非只有其通过继承实现的接口类, 也可以通过通用的查询方法获取其能够直接或间接控制到的接口对象.

COM 中查询接口的方法

COM 中查询接口的功能围绕着两个函数展开, 分别是 :

  • HRESULT __stdcall IUnknow::QueryInterface(_In_ REFIID, _Out_ LPVOID*); : 用于直接查询接口;
  • HRESULT __stdcall INonDelegatingUnknown::NonDelegatingQueryInterface(_In_ REFIID, _Out_ LPVOID*); : 用于非代理的形式查询接口;

所谓非代理的形式查询, 即只返回自身内部的接口, 而非需要通过继续查询成员对象的接口而间接获取的接口对象. 反之, 普通的可代理形式则是让被查询对象检查所有可被直接或间接控制到的接口对象. 但这两条规则仅仅只是约定, 并没有 OOP 中那么严格的限制. 由于这两个方法是纯虚函数, 因此对于被查询对象来说, 其具体想以什么方式向外界提供接口对象完全取决于自身实现.

COM 中每一个静态类类型在定义时都需要有一个用于标识类型的 GUID , 对于接口类(纯虚类)就被称作 IID , 对于实现类就被称作 CLSID . 所有 COM 类型都必须继承自 IUnknow 接口或INonDelegatingUnknown 接口. 当外部需要查询一个对象是否支持某种接口时, 则调用其 IUnknow::QueryInterface 方法并传入具体的 IID , 若该对象支持这种接口, 则通过返回参数项传递出来.

下面这个例子展示了 COM 中查询接口最基础的过程 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static constexpr IID IID_IX = {/* xxxxxx */};
class IX {
public: virtual HRESULT funcX() = 0;
};

class CA final : public IUnknow, public IX {
......
public: HRESULT __stdcall QueryInterface(_In_ REFIID iid, _Out_ LPVOID* ppv) {
if (IID_IUnknow == iid) {
*ppv = reinterpret_cast<IUnknow*>(this);
AddRef();
return S_OK;
}

if (IID_IX == iid) {
*ppv = reinterpret_cast<IX*>(this);
AddRef();
return S_OK;
}

return E_NOINTERFACE;
}

public: HRESULT speak() {
std::cout << "I am CA" << std::endl;
}
};

int main(int argc, char* argv[]) {
IUnknow* object; // 从外部进程获取的 CA 对象
IX* x = nullptr;
if (SUCCESSED(object->QueryInterface(IID_IX, &x))) {
x->funcX(); // --> I am CA
}

return 0;
}

上面的例子中, object 可以查询出 IX 接口对象, 即便 IUnknowIX 两个静态类型之间没有关系.

根据 COM 的约定 IX 其实也需要继承自 IUnknow , 但为了便于理解我们这里没有这么做.

COM 组件的包容概念

所谓包容, 即一个对象包含着另一个对象, 前者可以控制后者. 在 OOP 中说白了后者就是前者的成员对象. 在 COM 中我们把包含者称为外部对象, 把被包含着称为内部对象. 当对外部对象查询接口时, 外部对象除了可以提供自身所实现的接口以外, 还可以提供内部对象所实现的接口. 这就是包容的核心思想.

我们在上一节例子的基础上, 加入一些代码演示一个体现包容概念的查询过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
static constexpr IID IID_IY = {/* xxxxxx */};
class IY {
public: virtual HRESULT funcY() = 0;
};

class CB final : public IUnknow, public INonDelegatingUnknown, public IY {
......
public: HRESULT __stdcall QueryInterface(_In_ REFIID iid, _Out_ LPVOID* ppv) {
HRESULT hr = NonDelegatingQueryInterface(iid, ppv);
if (SUCCESSED(hr)) { return hr; }

return x_->QueryInterface(iid, ppv);
}

public: HRESULT __stdcall NonDelegatingQueryInterface(_In_ REFIID iid, _Out_ LPVOID* ppv) {
if (IID_IUnknow == iid) {
*ppv = reinterpret_cast<IUnknow*>(this);
AddRef();
return S_OK;
}

if (IID_INonDelegatingUnknown == iid) {
*ppv = reinterpret_cast<INonDelegatingUnknown*>(this);
AddRef();
return S_OK;
}

if (IID_IY == iid) {
*ppv = reinterpret_cast<IY*>(this);
AddRef();
return S_OK;
}

return E_NOINTERFACE;
}

public: HRESULT funcY() {
std::cout << "I am CB" << std::endl;
}

private: IX x_ = new CA;
};

int main(int argc, char* argv[]) {
IUnknow* object; // 从外部进程获取的 CB 对象

IX* x = nullptr;
if (SUCCESSED(object->QueryInterface(IID_IX, &x))) {
x->funcX(); // --> I am CA
}

IY* y = nullptr;
if (SUCCESSED(object->QueryInterface(IID_IY, &y))) {
y->funcX(); // --> I am CB
}

INonDelegatingUnknown* object_self = nullptr;
if (FAILED(object->QueryInterface(IID_INonDelegatingUnknown, &object_self))) {
return 0;
}

x = nullptr;
if (SUCCESSED(object_self->QueryInterface(IID_IX, &x))) {
x->funcX(); // 不会被执行
}

y = nullptr;
if (SUCCESSED(object_self->QueryInterface(IID_IY, &y))) {
y->funcX(); // --> I am CB
}

return 0;
}

上例中 object 作为 CB 类型的对象不但可以查询出自己实现的 IY 接口, 同时也可以查询出自己的内部对象 x_ 所实现的 IX 接口. 我们同时还让 CB 实现了 INonDelegatingUnknown 接口, 因此当我们使用 INonDelegatingUnknown 接口继进行非代理形式地查询时, 就无法查询到 IX 接口了.

COM 组件的聚合概念

所谓聚合, 即对象屏蔽自身一部分接口, 让用户可以在没有直接接触某接口指针的情况下, 调用该接口的函数. 请注意, 这与 OOP 中的多态概念不太一样. 在多态的概念下, 当我们调用基类的虚函数时, 实际执行的可能是某个我们不确定的具体子类的实现. 在聚合的概念下, 当我们调用一个接口的虚函数时, 实际执行的可能是另外一个我们不知道的接口的虚函数. 聚合的目的是让用户只需要关心过程, 将工作细节隐藏在组件内部中. 从这个角度上看, COM 中的聚合与 OOP 中的多态一定程度上是殊途同归的.

我们在上一小结例子的基础上做一些修改. 假设现在 CB 类不希望外部以代理的方式查询自己的接口. 但同时由于组件内部逻辑的需要, 又必须保留代理查询的方法. 此时我们可以将 CB 做出如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class CB final : public IUnknow, public INonDelegatingUnknown, public IY {
......
public: HRESULT __stdcall QueryInterface(_In_ REFIID iid, _Out_ LPVOID* ppv) {
HRESULT hr = NonDelegatingQueryInterface(iid, ppv);
if (SUCCESSED(hr)) { return hr; }

return x_->QueryInterface(iid, ppv);
}

public: HRESULT __stdcall NonDelegatingQueryInterface(_In_ REFIID iid, _Out_ LPVOID* ppv) {
if (IID_IUnknow == iid) {
*ppv = reinterpret_cast<INonDelegatingUnknown*>(this); // 注意这里, 被改动了
AddRef();
return S_OK;
}

if (IID_INonDelegatingUnknown == iid) {
*ppv = reinterpret_cast<INonDelegatingUnknown*>(this);
AddRef();
return S_OK;
}

if (IID_IY == iid) {
*ppv = reinterpret_cast<IY*>(this);
AddRef();
return S_OK;
}

return E_NOINTERFACE;
}

public: HRESULT funcY() {
std::cout << "I am CB" << std::endl;
}

private: IX x_ = new CA;
};

我们当外部查询 IUnknow 接口的时候, 将自身强转为 INonDelegatingUnknown 接口的指针再传递出去. 此时, 当外部进行接口查询时, 将出现如下情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int main(int argc, char* argv[]) {
// ---------- 组件进程 ----------
IUnknow* _object = new CB;

IX* x = nullptr;
if (SUCCESSED(object->QueryInterface(IID_IX, &x))) { // 执行了 QueryInterface
x->funcX(); // --> I am CA
}

IY* y = nullptr;
if (SUCCESSED(object->QueryInterface(IID_IY, &y))) { // 执行了 QueryInterface
y->funcX(); // --> I am CB
}

IUnknow* object = nullptr;

IY* y = nullptr;
if (SUCCESSED(object->QueryInterface(IID_IUnknow, &_object))) { // 执行了 QueryInterface
IUnknow* object = _object; // 用户进程查询出 IUnknow 接口
}

// ---------- 用户进程 ----------
object; // 从组件进程获取的 CB 对象

IX* x = nullptr;
if (SUCCESSED(object->QueryInterface(IID_IX, &x))) { // 实际上执行了 NonDelegatingQueryInterface
x->funcX(); // 不会被执行
}

IY* y = nullptr;
if (SUCCESSED(object->QueryInterface(IID_IY, &y))) { // 实际上执行了 NonDelegatingQueryInterface
y->funcX(); // --> I am CB
}

return 0;
}

可见, 在组件自身进程内部, 我们通过 IUnknow::QueryInterface 接口可以代理地查询出 CB 类所有支持的接口. 而在用户进程中, 我们所有调用 IUnknow::QueryInterface 函数的地方, 实际上调用了 INonDelegatingUnknown::NonDelegatingQueryInterface . 因此用户就被限制只能以非代理的形式查询接口, 而无需让用户手动调用非代理查询的方法.

这种功能的实现原理就在于 IUnknowINonDelegatingUnknown 这两个接口. 我们先看一下它们的定义:

1
2
3
4
5
6
7
8
9
10
11
struct IUnknown {
virtual HRESULT __stdcall QueryInterface(REFIID, LPVOID*) = 0;
virtual ULONG __stdcall AddRef(void) = 0;
virtual ULONG __stdcall Release(void) = 0;
};

struct INonDelegatingUnknown {
virtual HRESULT __stdcall NonDelegatingQueryInterface(REFIID, LPVOID*) = 0;
virtual ULONG __stdcall NonDelegatingAddRef(void) = 0;
virtual ULONG __stdcall NonDelegatingRelease(void) = 0;
};

可见它们之间除了类名与函数名不一样以外, 所有的定义细节都是一致. 这就决定了这两个类在内存中虚函数表的结构是完全一致的. 因此当我们使用 IUnknown 的指针调用一个实际指向 INonDelegatingUnknown 接口的对象时. 调用的实际上是 INonDelegatingUnknown 虚函数表中与 IUnknown 虚函数表里当前调用方法位置相对应的那个方法. 因此在上例中我们在用户进程调用 IUnknow::QueryInterface 但实际执行的是 INonDelegatingUnknown::NonDelegatingQueryInterface .

但同时我们在 CB 中的修改也是必须的. 这是因为 进行强制类型转换的过程中, 若转换前后两个静态类型之间本身存在继承关系, 则转换后指针将使用转换后类型的虚函数表. 而当转换前后两个静态类型之间不存在继承关系, 则转换后指针将依然使用转换前类型的虚函数表. 因此若我们没有在 CB 内部将 CB 强转为 INonDelegatingUnknown, 则会在返回后进行 CBIUnknow 的强转. 那么用户获取到的 IUnknow 指针将绑定到 IUnknow 的虚函数表, 也就取得代理查询的功能了, 这不是我们所期望的.

C/C++ 中的强制类型转换与 C# 或 Java 中的强制类型转换是不一样的概念. 后两者的强制类型转换实际上是 C++ 中的动态类型转换, 因为它们都依赖于动态运行时环境.


总结

当你通篇读完本文后, 应该能感觉到实现虚拟摄像头本身并不是一个很复杂的工作. 我们本质上要做的就是一个 DirectShow Source Filter , 将其冒充为一个物理摄像头在 DirectShow 中所对应的 Filter. 其中需要注意的就是许多物理摄像头中理所当然的特性同样是需要我们自己手动模拟的.

这个工作真正麻烦的地方在于了解 COM 的思想和 COM 组件 / DirectShow 组件的开发过程. 在我学习 COM 和 DirectShow 这段时间能体会到, 它们本身是一个很好但同时也很落伍的设计. 它们诞生于许多现代高级语言的特性还不存在之前, 但却通过各种巧妙地设计实现了许多高级的语法功能. 但严格地从现代的角度来看, 它们无非是解决了一些如今已经不存在的问题. 并且由于设计中大量地依赖于约定, 许多接口的抽象程度已经到了难以理解的程度, 导致它们对开发者的要求很高. 或许这些也是 DirectShow 如今逐渐没落的原因之一吧.