简介:一套基于DirectShow框架实现VCAM虚拟摄像头的完整工程,面向需要掌握Windows多媒体开发、虚拟摄像头设计与系统集成的中高级开发者。项目覆盖源过滤器创建、视频数据生成、过滤图构建、设备注册与性能优化等核心环节,可用于摄像头模拟、视频流测试及第三方软件对接。压缩包共154个文件,包含C++头文件(.h)与实现文件(.cpp)、Visual Studio工程文件(.vcxproj/.sln)、DirectShow滤镜文件(.ax)、编译中间文件(.obj)以及调试符号(.pdb)等,整体大小约71.33MB,目录结构清晰。已有2450人学习下载。通过阅读项目代码,读者能深入掌握DirectShow过滤器与Filter Graph Manager的协作机制,理解Sample Grabber抓帧、VMR视频渲染、虚拟设备注册和多线程处理等关键技术,为开发自定义视频采集与处理方案提供稳定起点。
1. 先别急着找驱动:VCAM 虚拟摄像头是往系统里塞一个“可填充”的视频设备
如果你电脑没有摄像头,又要在视频会议软件里露脸,你会在设备列表里碰到要么黑屏、要么干脆没有输入的尴尬。VCAM 虚拟摄像头就是用来解决这个问题的:它通过 DirectShow 滤镜伪装成一台采集设备,让各路视频软件正常把它当成摄像头。我最近把一套 VCAM 开源资源从头拆到尾,从注册到在视频软件里看到画面,中间踩了不少坑。适合两类人:一类是想给产品补一个模拟摄像头通道的开发者;另一类是排障工程师,想在无硬件环境下复现视频采集链路。下面是我整个拆包过程的技术记录。
2. 为什么选 DirectShow 做虚拟摄像头:滤镜管线与媒体格式协商
2.1 技术路线对比:WDM、Media Foundation、DirectShow 哪个更适合模拟输入
虚拟摄像头绕不开一个问题:怎么让你写出来的用户态程序被系统当成真正的视频输入设备。视频采集框架分好几派,各自的注册方式、兼容范围、开发成本都不一样。
| 技术路线 | 典型机制 | 开发成本 | 兼容面 | 维护性 |
|---|---|---|---|---|
| WDM/KS 驱动 | 内核流式驱动 | 高,需要驱动签名 | 最广,硬件级 | 蓝屏、调试门槛高 |
| Media Foundation 源 | 用户态 MFT 或 Source | 中高 | 新程序为主,老软件要适配层 | API 更新频繁 |
| 用户态镜像驱动 | 旧式镜像屏 | 低 | 老系统才认,新系统基本失效 | 已被系统放弃 |
| DirectShow 自定义滤镜 | Push Source 输出 Pin | 中低 | 大量存量视频软件可直接识别 | 接口稳定、资料多 |
VCAM 走的是第四种。很多视频会议、录屏、直播软件内部还保留着 DirectShow 视频捕获枚举,它们启动时会去系统里找“视频捕获设备”。你做一个 Media Foundation 源,很多老软件根本不认,等于一个说英语的人和一群只讲粤语的人对话,还要自己加翻译层。DirectShow 滤镜则只要在注册表里挂一个 CLSID,这些软件自己就会在列表里看到它。这是这个方案最大的优势:软件层面伪装,注册层面可见。
当然,DirectShow 也不是没有局限。新出的 UWP 应用基本不走这条路,它更适合传统 Win32 软件;内存模型需要自己管理。但如果你是奔着“让一堆老视频软件认出一个虚拟摄像头”去的,它是最稳的选择。VCAM 资源包里的核心实现,就是一个从CSourceStream派生的输出 Pin,外加一个.rgs注册脚本和喂帧用的 Demo 程序。
2.2 一个滤镜是如何变成“摄像头”的:Filter、Pin 与媒体类型
DirectShow 里最核心的模型是 Filter Graph,由滤镜、引脚和连接关系构成。当应用去枚举视频输入设备时,系统会找到你注册的这个滤镜;它把自己暴露成 Capture Source Filter。滤镜内部是一个继承自CSourceStream的输出 Pin,向 Graph 宣告:我有视频数据可以推给你。
要让下游软件认你,先得过媒体类型协商这一关。DirectShow 里的AM_MEDIA_TYPE结构体描述格式,包含主类型MEDIATYPE_Video,子类型MEDIASUBTYPE_RGB24或MEDIASUBTYPE_YUY2,以及一个VIDEOINFOHEADER,里面带分辨率、帧率、像素纵横比。下游会通过IEnumMediaTypes枚举你的输出 Pin 支持的格式,然后挑一个它最熟的去连接。
一个合格的模拟摄像头至少要支持两种像素格式:RGB24 和 YUY2。老视频软件多认 RGB24,编码采集软件多接 YUY2 或 I420,只给一种会损失大量兼容性。分辨率上,我建议按 640x480、1280x720、1920x1080 一档一档列出来。CheckMediaType里要比对subtype、bmiHeader.biWidth、biHeight,不要只比对子类型就放行,否则后面切分辨率时会出现花屏和断流。
2.3 数据流走向:源帧如何变成设备时钟上的视频帧
VCAM 是一条流水线:源数据(图片循环、屏幕捕获、视频文件解码)→ VCAM 输出 Pin 的FillBuffer→ 下游接收器。Graph 每次要一帧,就会调用FillBuffer。你要做的是拿IMediaSample指针,GetPointer拿到输出缓冲,往里填数据,SetTime打时间戳,再交给下游。
我简化后的核心代码大概是这样的:
// VCAMVideoPin::FillBuffer - 每帧被 Graph 调用 HRESULT VCAMVideoPin::FillBuffer(IMediaSample *pSample) { BYTE *pBuffer = nullptr; pSample->GetPointer(&pBuffer); // 获取输出缓冲 long nSize = pSample->GetSize(); // 系统按媒体类型分配的缓冲大小 const int frameBytes = m_width * m_height * 3; // RGB24 三字节排列 if (nSize >= frameBytes) { // 从源对象拿帧,源帧必须与当前媒体类型分辨率一致 memcpy(pBuffer, m_source->GetFrame(), frameBytes); } else { // 缓冲不足时宁可填零,也别越界写内存 memset(pBuffer, 0, nSize); } // 每帧间隔以 100ns 为单位:10000000 / fps const REFERENCE_TIME interval = 10000000 / m_fps; REFERENCE_TIME rtStart = m_rtStart; REFERENCE_TIME rtEnd = rtStart + interval; pSample->SetTime(&rtStart, &rtEnd); // 时间戳是下游识别视频时序的依据 m_rtStart = rtStart + interval; return S_OK; }这里的参数直接决定“设备真实感”。m_fps是两帧之间的间隔,不是“每秒帧数”的简单倒数,DirectShow 的时间单位是 100ns,所以 30fps 对应 333333 个刻度。m_rtStart要在SetMediaType或滤镜Start时重置成零,否则切换格式后时间戳会带着旧累积,下游播放会卡顿。
有人会问:为什么不用系统当前时间直接打时间戳?因为虚拟摄像头没有硬件时钟,真实时间会有抖动,下游软件拿到的帧间隔忽大忽小。常见做法是让 VCAM 内部的计数器按固定间隔递增,这样 Graph 看到的是一条稳定时钟。这一条后面还会引出帧率统计的坑。
3. 把 VCAM 跑起来:编译、注册、喂帧三步走
3.1 拿到资源后先看清目录结构和环境要求
VCAM 资源包的整体结构一般是这样:
VCAM_Source/ VCAM.sln VCAMFilters/ VCAMVideoPin.cpp VCAMVideoPin.h VCAMFilter.cpp Register/ register.bat unregister.bat VCAM.rgs Demo/ DemoSource.cpp feed_bmp.bat环境上,只要装了带 DirectShow 库的系统 SDK 就行。构建时你可能会遇到一个尴尬事:只生成 x86 版本,结果某 64 位软件看不到设备;只生成 x64 版本,结果老视频软件是 32 位进程,也看不到。所以我一拿到项目就先改两个配置:Release x86 和 Release x64,分别输出。它们可以用同一个 CLSID,但 DLL 路径不能混。
构建命令行我习惯这样写:
# 先设置系统 SDK 环境 call "C:\Program Files (x86)\Windows Kits\10\bin\vcvarsall.bat" x86 cd VCAM_Source msbuild VCAM.sln /p:Configuration=Release /p:Platform=x86如果你拿到的是 IDE 工程,直接在界面里选 Release x86 和 x64 各生成一遍。输出目录里的VCAM.ax就是待注册的动态库,它本质是一个 DLL,系统靠扩展名识别成滤镜文件。我习惯在输出目录里单独放一个VCAM.ax和两个注册脚本,避免每次编译完还要手动复制。
3.2 注册与卸载:一个 .rgs 文件在系统里埋了什么
注册步骤是所有环节里最直白、也最容易出问题的。以管理员权限打开命令行,执行:
# 注册 32 位滤镜 regsvr32 /s build\x86\VCAM.ax # 注册 64 位滤镜 regsvr32 /s build\x64\VCAM.ax重装或更新滤镜前,一定要先卸载:
regsvr32 /u /s build\x86\VCAM.ax regsvr32 /u /s build\x64\VCAM.axregsvr32做的事,是加载 DLL 后调用DllRegisterServer。这个函数会读取.rgs文件,把滤镜的 CLSID、FriendlyName、FilterData、支持的媒体类型写进注册表。.rgs不是摆设,如果你改了滤镜支持的媒体类型,或者想改显示在视频软件里的设备名,除了改代码,还要同步改.rgs后再重新注册。
注册完成后,你可以在注册表里核对:HKEY_CLASSES_ROOT\CLSID\{你的GUID}\InprocServer32默认值是否指向 VCAM.ax 的实际路径。如果路径是旧的,很多应用会直接忽略这个设备。还有一个细节:32 位滤镜和 64 位滤镜要用同一个 GUID,但注册表InprocServer32下的路径不能写错,否则系统加载滤镜时找不到 DLL。
3.3 喂数据:怎么让 VCAM 真的一帧帧往外吐画面
VCAM 不创造视频,它需要外部喂源。常见的喂法有三种:
- 定时读取一张 BMP 作为静止帧循环发送;
- 定时截取某个窗口或整个屏幕,转换成 RGB24 后塞进 VCAM;
- 用开源解码库读取视频文件,逐帧转换后送入。
资源包里的 Demo 实现了第一种。它启动后开一个独立线程,按m_fps的间隔调用一次填充逻辑,把 BMP 像素数据复制到 VCAM 输出 Pin 的内部缓冲。这个设计看起来简单,但有一点需要注意:Graph 内部有自己线程,它会主动调FillBuffer,如果你的源线程也在往同一个缓冲里写,两边就会竞争同一块内存。最稳妥的方式是源数据放在滤镜类内部,由滤镜自己的线程去刷新,外部通过共享内存或消息通知它“下一帧准备好了”。
包里的 Demo 程序调用方式类似:
DemoSource.exe --input face.bmp --width 640 --height 480 --fps 15启动后它会持续打印“frame delivered”。当你在视频软件里选中 VCAM,看到脸图出现,说明滤镜、注册、数据源三段全部打通。如果一直黑屏,下一步就要按避坑章节来排查。
4. 避坑指南:VCAM 虚拟摄像头的五个研发踩坑记录
4.1 注册成功但应用列表里找不到摄像头
现象:regsvr32显示成功,但打开视频软件,摄像头列表里什么都没有。
原因:最常见的是位数不匹配。视频软件如果是 32 位进程,它只会枚举 32 位滤镜;如果是 64 位进程,则只认 64 位滤镜。另一个原因是系统视频设备枚举缓存没有刷新,设备名被旧条目盖住了。
解决:先把 32 位和 64 位两个版本的 VCAM.ax 都注册一遍。如果还不行,写一个小工具,用系统枚举器刷新一次视频输入设备列表,再重新打开视频软件。那次之后我养成了一个习惯:注册完先跑一个枚举工具确认设备在列表里,再进视频软件。
4.2 画面黑屏但滤镜图连接正常
现象:在滤镜调试工具里能看到 VCAM,连接成功,但预览画面是全黑的。
原因:数据长度不匹配或像素格式不匹配。很多时候你预设的是 RGB24,但下游软件请求的是 YUY2,系统分配的是 YUY2 缓冲区;你用 RGB24 的字节数往里填,写不满或越界,画面自然就是黑的。
解决:在FillBuffer里先读当前媒体类型,判断subtype,再决定拷贝逻辑。同时检查bmiHeader.biSizeImage是否正确。这里有一个隐蔽坑:biSizeImage必须按 stride 对齐计算,不能直接写width * height * 3。正确的公式是((width * 3 + 3) & ~3) * height。我前面几次黑屏都是这个字段写小了,系统分配的缓冲不足,导致后续帧数据缺失。
4.3 软件能选但切不了分辨率
现象:视频软件里能看到 1280x720 这个选项,但切过去画面花屏,或者干脆断流。
原因:SetMediaType里没有同步更新内部几何参数。CheckMediaType验证通过后,滤镜内部的m_width、m_height、stride、biSizeImage还是 640x480 的旧值。下游按 1280x720 分配缓冲,你还用 640x480 的 stride 去拷贝,每行错位,花屏就来了。
解决:在SetMediaType里把所有尺寸相关的成员全部更新,并重建源数据缓冲。我习惯写一个ReallocSource函数,专门处理分辨率变化:先释放旧位图,按新宽度计算 stride,再分配新缓冲。这样切换格式后第一帧就是干净的数据。
4.4 长时间运行后内存和句柄持续增长
现象:运行两个小时后系统提示 GDI 句柄不足,滤镜输出停滞,视频软件卡死。
原因:源位图每次刷新都新建却没有释放;或者FillBuffer里new了一个临时缓冲,却没有配套delete。DirectShow 滤镜会被反复 Start/Stop,每次 Stop 都要做清理。
解决:源位图作为滤镜类成员创建,Stop时统一释放,不要在FillBuffer里频繁分配。位图相关的 DC 和 GDI 对象要成对创建销毁,DeleteObject不能漏。我用句柄计数工具在 Start/Stop 前后各抓一次,确保句柄数回到同一个基线。达到这个标准后,才能放心把 VCAM 挂到会议软件上跑一天。
4.5 统计的帧率永远是 30fps,与源帧率无关
现象:源数据明明只有 5fps,但视频软件统计出来的帧率是 30fps。
原因:媒体类型里的AvgTimePerFrame写的是默认 30fps,或者FillBuffer里根本没设置时间戳。下游软件按默认帧率估算所有帧的间隔,于是一秒看起来就是 30 次刷新。
解决:EnumMediaTypes里返回的每个媒体类型,都要把AvgTimePerFrame设置成与m_fps匹配的值;FillBuffer里用累计帧数乘间隔,而不是用系统当前时间。打时间戳后,视频软件统计出的帧率就与源帧率对上了。
5. 验证与进阶:用设备枚举器和参数表把 VCAM 优化到可交付状态
5.1 用设备枚举器快速确认 VCAM 是否被系统识别
我每次注册完都会跑一段小工具,直接用系统枚举器列出所有视频输入设备。代码很短:
// enum_vcam.cpp - 枚举系统所有视频输入设备 HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); ICreateDevEnum *pDevEnum = nullptr; hr = CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pDevEnum)); if (FAILED(hr)) { return hr; } IEnumMoniker *pEnum = nullptr; hr = pDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &pEnum, 0); if (hr == S_OK) { IMoniker *pMoniker = nullptr; while (pEnum->Next(1, &pMoniker, nullptr) == S_OK) { // 这里把显示名打印到控制台 LPOLESTR displayName = nullptr; pMoniker->GetDisplayName(nullptr, nullptr, &displayName); // 过滤判定是否为 VCAM 设备,再输出 pMoniker->Release(); } } pEnum->Release(); pDevEnum->Release();这段代码比打开视频软件快得多,而且能分辨到底是系统枚举不到设备,还是软件自己的缓存问题。如果这里能看到 VCAM,视频软件里还看不到,问题就出在软件缓存或进程位数上,和滤镜本身无关。
5.2 参数调优的两条习惯
我把分辨率、帧率、像素格式做成三个可调参数,而不是散落的宏。每次改完参数,要同步更新四个地方:VIDEOINFOHEADER里的bmiHeader、rcSource、AvgTimePerFrame、biSizeImage,以及源缓冲区里的 stride。漏掉任何一个,都会变成奇怪的黑屏或花屏。
从那以后,每次改 VCAM 版本,我都会强制走一遍完整流程:先跑上面的枚举工具确认设备在位,再在视频软件里从最低分辨率切到最高分辨率,最后挂机一小时看句柄和内存曲线。十分钟的检测能挡住大半的交付事故。希望帮到你。
本文还有配套的精品资源,点击获取