简介:这是一份基于 DirectShow 实现 VCAM 虚拟摄像头的完整工程源码,面向具备 C++ 与 Windows 多媒体开发基础、需要在无物理摄像头环境下生成视频流用于测试、演示或软件集成的开发者。压缩包共 154 个文件,约 71.33MB,包含 38 个 .h 与 33 个 .cpp 源文件、.ax 过滤器模块,以及 Visual Studio 解决方案与工程配置、obj 和 pdb 等编译产物,便于查阅实现并二次构建。已有 2450 人学习下载。资源围绕虚拟源过滤器设计、过滤图构建、视频数据生成、系统设备注册与兼容性处理展开,对源过滤器、转换过滤器与渲染过滤器之间的协作关系也有直观呈现,可帮助读者把握虚拟摄像头从数据产生到被其他应用枚举调用的完整链路,并参考其中的多线程、错误处理与性能优化思路,搭建满足测试演示、直播推流或软件集成需求的 VCAM 方案。
1. 一个 DLL 对应一个摄像头:VCAM 虚拟摄像头适合哪些场景
演示机上没摄像头、要给会议客户端喂一段合成画面、或者把一路处理好的视频流塞进只认 DirectShow 采集设备的软件——VCAM 虚拟摄像头就是干这个的:编出一个进程内 COM 组件(.dll),注册进 DirectShow 的 VideoInputDevice 分类,之后任何按分类枚举摄像头的程序就会把它当成真实设备列出来。它的好处是「不用内核驱动、不用签名、随装随卸」,出问题反注册一下就干净了,不会像驱动那样留下一堆设备残留。适合做录屏直播二次开发、无人值守视频链路、给老系统喂测试画面的从业者。代价也要提前讲清楚:它只活在 DirectShow 世界里,走 Media Foundation 的应用看不见它,这条边界会在后面反复被提到。
2. 拆骨架:CSource、CSourceStream 与 VideoInputDevice 注册三件套
2.1 为什么是用户态源过滤器,而不是驱动或 Media Foundation
同样是「假装成一个摄像头」,业内有四条路,选哪条直接决定后面三个月的加班量。
| 方案 | 实现位置 | 签名要求 | 覆盖应用 | 崩溃影响 | 卸载干净度 |
|---|---|---|---|---|---|
| 内核 AVStream 小驱动 | 内核态 | 必须签名,测试模式很麻烦 | 全部 | 蓝屏 | 差,需卸设备 |
| DirectShow 源过滤器 | 用户态 DLL | 不需要 | 仅走 DShow 采集的程序 | 只崩宿主进程 | 好,两处注册表键 |
| Media Foundation 虚拟摄像头 | 用户态 DLL | 不需要 | 走 MF 的新程序 | 只崩宿主进程 | 好 |
| 屏幕/窗口捕获注入 | 宿主进程内 | 不需要 | 单一目标 | 崩溃不可控 | 看实现 |
VCAM 虚拟摄像头走的是第二条。选它的理由很实在:DirectShow 的 filter graph 全在用户态跑,参数协商、缓冲分配、时间戳这些出问题都能用调试器单步进去看;注册只写HKCR\CLSID\{...}\InprocServer32和分类下的一个 Instance 键,反注册就删掉;没有签名流程,改一版编一版,五分钟能验证。血泪经验是:只要碰内核驱动,测试机就得多备一台,因为一次蓝屏能把你半天的调试状态全清掉。
不足也要说清楚。Win10 之后浏览器、系统相机、不少新版会议客户端都转到了 Media Foundation 采集,这些程序在设备列表里根本不会去找 DirectShow 分类。所以判断值不值得用的第一条标准是:目标软件在设置里有没有「DirectShow」这个选项,或者它是不是一个老版本的采集/录屏/播放工具。如果目标程序只认 MF,那这份资源的适用范围就到不了那里,得再补一层 MF 虚拟摄像头,这是选型阶段就该定的事。
2.2 最小可跑的类结构:从 CSource 派生到 CSourceStream
一个能跑起来的虚拟摄像头只需要两个类,很多人以为还要手写一个 Pin 类,其实CSourceStream本身就是CBaseOutputPin的子类,它既是流控制对象又是那个输出引脚。
// VCam.h —— 虚拟摄像头最小骨架 class CVCamStream : public CSourceStream { public: CVCamStream(HRESULT *phr, CVCam *pParent, LPCWSTR pPinName); // 基类纯虚接口:先谈格式,再填数据 HRESULT GetMediaType(int iPosition, CMediaType *pmt); HRESULT CheckMediaType(const CMediaType *pmt); HRESULT DecideBufferSize(IMemAllocator *pAlloc, ALLOCATOR_PROPERTIES *pReq); HRESULT FillBuffer(IMediaSample *pSample); // 暴露 IAMStreamConfig,应用才能改分辨率和帧率 STDMETHODIMP NonDelegatingQueryInterface(REFIID riid, void **ppv); DECLARE_IUNKNOWN private: REFERENCE_TIME m_rtLast; // 上一帧结束时间,用于时间戳递推 CCritSec m_csFrame; // 保护共享帧缓冲 LONG m_lastSeq; // 共享内存帧序号,见第 6 章 }; class CVCam : public CSource { public: static CUnknown *WINAPI CreateInstance(LPUNKNOWN lpunk, HRESULT *phr); DECLARE_IUNKNOWN private: CVCam(LPUNKNOWN lpunk, HRESULT *phr); // 构造里挂上唯一的输出流 };构造函数里最关键的三行是引脚注册,写错就是「过滤器能创建但没有输出引脚」,应用连图都建不起来:
// VCam.cpp —— 构造时把唯一的输出流挂到过滤器上 CVCam::CVCam(LPUNKNOWN lpunk, HRESULT *phr) : CSource(NAME("VCAM Virtual Camera"), lpunk, CLSID_VCam) { m_iPins = 1; m_paStreams = (CSourceStream**)new CVCamStream*[1]; // 失败时把 hr 传下去,基类会据此拒绝创建整个过滤器 m_paStreams[0] = new CVCamStream(phr, this, L"Output"); }m_iPins和m_paStreams是CSource内部用来响应FindPin、EnumPins的,必须成对设置;CLSID_VCam要和后面注册用的 GUID 完全一致,建议只在一处DEFINE_GUID,其他地方全部引用,避免改了头文件忘了注册信息这种低级翻车。
2.3 注册表里到底落了什么:CLSID 与 VideoInputDeviceCategory
基类那套AMovieDllRegisterServer2默认把过滤器注册到CLSID_LegacyAmFilterCategory,也就是 GraphStudioNext 左边那个「DirectShow Filters」长列表。放在那里,应用枚举摄像头时永远看不到你。要做成摄像头,必须自己调IFilterMapper2::RegisterFilter显式指定CLSID_VideoInputDeviceCategory。
// VCam.cpp —— 手工注册到视频输入设备分类,这一步不能省 static const AMOVIESETUP_PIN psudPins[] = { { L"Output", FALSE, TRUE, FALSE, FALSE, &CLSID_NULL, NULL, 0, NULL } }; static const AMOVIESETUP_FILTER sudFilter = { &CLSID_VCam, // 与 CSource 构造里的一致 L"VCAM Virtual Camera", // 设备列表里显示的名字 MERIT_NORMAL, // 枚举阶段影响不大,建图时才有意义 1, psudPins }; CFactoryTemplate g_Templates[] = { { L"VCAM Virtual Camera", &CLSID_VCam, CVCam::CreateInstance, NULL, &sudFilter } }; int g_cTemplates = sizeof(g_Templates) / sizeof(g_Templates[0]); STDAPI DllRegisterServer() { HRESULT hr = AMovieDllRegisterServer2(TRUE); // COM 注册 + Legacy 分类 if (FAILED(hr)) return hr; IFilterMapper2 *pFM2 = NULL; hr = CoCreateInstance(CLSID_FilterMapper2, NULL, CLSCTX_INPROC_SERVER, IID_IFilterMapper2, (void**)&pFM2); if (FAILED(hr)) return hr; // 第 4 个参数决定它出现在哪个分类,摄像头必须是 VideoInputDevice hr = pFM2->RegisterFilter(CLSID_VCam, L"VCAM Virtual Camera", NULL, &CLSID_VideoInputDeviceCategory, NULL, &sudFilter); pFM2->Release(); return hr; }注册成功后,注册表里会出现两条关键路径:HKCR\CLSID\{你的CLSID}\InprocServer32指向 DLL 全路径,以及HKCR\CLSID\{860BB310-5D01-11D0-BD3B-00A0C911CE86}\Instance\{你的CLSID},后者下面有FriendlyName、CLSID、FilterData三个值。860BB310这个就是 VideoInputDevice 分类的固定 GUID,排查时直接看这个键在不在,比在应用里反复刷新列表快得多。反注册时对应调UnregisterFilter,两处都要清,只清一处就会出现「卸载了但设备还在」的玄学现象。
3. 把画面塞进去:媒体类型协商、FillBuffer 与缓冲区分配
3.1 GetMediaType / CheckMediaType:先谈好格式再谈数据
DirectShow 建图时,下游会从iPosition = 0开始不断调GetMediaType,直到你返回VFW_S_NO_MORE_ITEMS。这里的原则是「列表要收敛」:暴露两三种常用格式就够了,列十几个只会让应用逐个尝试、建图变慢,还容易在某一种上协商失败把整个连接拖崩。
// 只暴露 RGB24 和 YUY2 两种,分辨率统一 1280x720 HRESULT CVCamStream::GetMediaType(int iPosition, CMediaType *pmt) { if (iPosition < 0) return E_INVALIDARG; if (iPosition > 1) return VFW_S_NO_MORE_ITEMS; // 告诉调用方没有了 pmt->SetType(&MEDIATYPE_Video); pmt->SetSubtype(iPosition == 0 ? &MEDIASUBTYPE_RGB24 : &MEDIASUBTYPE_YUY2); pmt->SetFormatType(&FORMAT_VideoInfo); VIDEOINFOHEADER *pvi = (VIDEOINFOHEADER*)pmt->AllocFormatBuffer(sizeof(VIDEOINFOHEADER)); if (!pvi) return E_OUTOFMEMORY; ZeroMemory(pvi, sizeof(VIDEOINFOHEADER)); pvi->bmiHeader.biSize = sizeof(BITMAPINFOHEADER); pvi->bmiHeader.biWidth = 1280; pvi->bmiHeader.biHeight = 720; // 正数 = 自下而上,写帧时要翻 pvi->bmiHeader.biPlanes = 1; pvi->bmiHeader.biBitCount = (iPosition == 0) ? 24 : 16; pvi->bmiHeader.biCompression = (iPosition == 0) ? BI_RGB : MAKEFOURCC('Y','U','Y','2'); pvi->bmiHeader.biSizeImage = 1280 * 720 * ((iPosition == 0) ? 3 : 2); pvi->AvgTimePerFrame = 333333; // 单位 100ns,30fps pvi->rcTarget = pvi->rcSource = RECT{0, 0, 1280, 720}; pmt->SetSampleSize(pvi->bmiHeader.biSizeImage); // 不设容易拿到偏小缓冲 return S_OK; }AvgTimePerFrame的单位是 100 纳秒,25fps 是 400000,30fps 是 333333,60fps 是 166667。这个值不只是给下游看的,FillBuffer里递推时间戳也必须用它,两边不一致就会出现「画面按 30fps 出、时间戳按 25fps 走」,下游按时间戳播放时表现为轻微的慢放或快放,很隐蔽。
CheckMediaType是反向校验,下游拿着一个类型来问「这个行不行」,你的实现要把不支持的挡回去,而不是一律返回 S_OK——一律放行会让下游以为 4K 也能连,真正开始传数据时才发现缓冲不够,表现为连接成功但一帧都出不来。
3.2 FillBuffer 里的时间戳与帧节奏
FillBuffer是这个组件里唯一每帧都会被调到的函数,它的正确性直接决定画面能不能出来。
HRESULT CVCamStream::FillBuffer(IMediaSample *pSample) { BYTE *pData = NULL; if (FAILED(pSample->GetPointer(&pData))) return E_FAIL; const VIDEOINFOHEADER *pvi = (const VIDEOINFOHEADER*)m_mt.pbFormat; // 每行按 4 字节对齐,1280 宽 RGB24 刚好是 3840,1366 就不是了 const LONG lStride = ((pvi->bmiHeader.biWidth * pvi->bmiHeader.biBitCount + 31) / 32) * 4; const LONG lNeed = lStride * pvi->bmiHeader.biHeight; if (pSample->GetSize() < lNeed) return E_FAIL; // 宁失败,不越界 { CAutoLock lock(&m_csFrame); CopyFrameToDib(pData, lStride); // 第 6 章的共享内存读在这里 } pSample->SetActualDataLength(lNeed); // 时间戳必须单调递增,步长与 AvgTimePerFrame 一致 REFERENCE_TIME rtStart = m_rtLast; REFERENCE_TIME rtStop = rtStart + pvi->AvgTimePerFrame; m_rtLast = rtStop; pSample->SetTime(&rtStart, &rtStop); pSample->SetSyncPoint(TRUE); return S_OK; // 注意:返回 S_FALSE 会让基类认为流结束,画面直接停住 }有三个点值得单独说。第一,SetActualDataLength必须调,不调的话下游拿到的是分配器给的长度,RGB24 下通常没问题,YUY2 或分辨率一变就会多传一截脏数据,表现为画面右侧一条彩条。第二,返回S_FALSE是个经典翻车点,很多人想用它表示「这帧先不给你」,结果整个流被基类判定为结束,画面直接冻住,正确的做法是照常填一帧(哪怕是重复的上一帧)。第三,m_rtLast的初值要在OnThreadStartPlay里清零或者对齐参考时钟,否则第一次启动时时间戳可能是个巨大的数,下游会等很久才开始播。
节奏控制这块,基类的DoBufferProcessingLoop是GetDeliveryBuffer → FillBuffer → Deliver的紧循环,本身不 sleep。下游是真实渲染器时,分配器满了会阻塞Deliver,天然限速;但如果下游挂的是 Null Renderer 做压测,它会全速跑,一核 CPU 直接打满。我一般会在FillBuffer结尾按剩余时间补一个短睡眠,或者用m_pClock->GetTime()对齐绝对时间、落后太多就丢帧推进m_rtLast,后者在长时间运行下更稳,不会累积漂移。
3.3 DecideBufferSize 与 stride:少算一个字节就花屏
分配器给多大缓冲,是下游问、你来答的。这里最容易被忽略的是「实际值」和「请求值」可能不一样。
HRESULT CVCamStream::DecideBufferSize(IMemAllocator *pAlloc, ALLOCATOR_PROPERTIES *pReq) { CAutoLock lock(GetStateLock()); const VIDEOINFOHEADER *pvi = (const VIDEOINFOHEADER*)m_mt.pbFormat; const LONG lStride = ((pvi->bmiHeader.biWidth * pvi->bmiHeader.biBitCount + 31) / 32) * 4; pReq->cBuffers = 2; // 双缓冲,延迟最低 pReq->cbBuffer = lStride * abs(pvi->bmiHeader.biHeight); pReq->cbAlign = 1; pReq->cbPrefix = 0; ALLOCATOR_PROPERTIES actual; HRESULT hr = pAlloc->SetProperties(pReq, &actual); if (FAILED(hr)) return hr; if (actual.cbBuffer < pReq->cbBuffer) return E_FAIL; // 给少了直接拒 return S_OK; }cBuffers开 2 就够,开 4、5 只会让端到端延迟变大,做实时会议时能明显感觉出来。biHeight用abs是因为负数表示自上而下,两种都要能处理。
stride 对齐这块,RGB24 每像素 3 字节,行宽必须是 4 的倍数:1280×3=3840 天然对齐,1920×3=5760 也对齐,但 1366×3=4098 就要补到 4100。补的那两个字节不写会读到上一行尾部,画面表现为逐行错位的斜纹。另外biHeight为正数时 DIB 是自下而上存储的,写第 y 行实际要落到(height - 1 - y) * stride,这个翻转忘了就是上下颠倒,一眼能看出来但也最容易在改分辨率之后复发。
从 RGB 转 YUY2 时,两个相邻像素共享一组色度分量,每 4 字节表示 2 个像素:Y0 U Y1 V。整数近似常用Y = (77R + 150G + 29B) >> 8、U = ((-43R - 85G + 128B) >> 8) + 128、V = ((128R - 107G - 21B) >> 8) + 128,注意 U/V 要 clamp 到 0~255,不 clamp 会在高饱和区域出现溢出的花斑。
4. 编译与注册的实际步骤:从 BaseClasses 依赖到设备枚举验证
4.1 工程配置:头文件、库与 .def 导出
现代 Visual Studio 不再自带 DirectShow BaseClasses,得从旧版 SDK 或公开的 baseclasses 工程拿一份,单独编成静态库(baseclasses.lib)。工程里要做的事不多但都不能漏:附加包含目录指向 baseclasses 头文件,附加库目录指向编出来的 lib,链接strmiids.lib、quartz.lib、ole32.lib、uuid.lib、winmm.lib。字符集统一用 Unicode,baseclasses 里大量TCHAR,混用多字节会在一堆LPCTSTR上编译报错。
导出函数必须齐四个,缺一个就是典型的「注册失败但看不出原因」:
// VCam.def —— COM 进程内服务器必须导出这四个 LIBRARY VCam EXPORTS DllGetClassObject PRIVATE DllCanUnloadNow PRIVATE DllRegisterServer PRIVATE DllUnregisterServer PRIVATE漏掉DllGetClassObject时,regsvr32可能返回成功(因为DllRegisterServer只是写注册表),但应用一创建过滤器就报0x80040111(类工厂无法创建该类)。这个错误码看到就该先查导出表,别去翻注册表。
4.2 DllRegisterServer 与 regsvr32 的位数坑
注册命令本身很简单,坑全在位数上。32 位 DLL 必须用SysWOW64下的 regsvr32,64 位 DLL 用System32下的,反了就是「注册成功但应用找不到」。
:: 64 位 DLL —— 用 System32 的 regsvr32,需要管理员权限 C:\Windows\System32\regsvr32.exe /s C:\vcam\x64\Release\VCam.dll :: 32 位 DLL —— 用 SysWOW64 的 regsvr32 C:\Windows\SysWOW64\regsvr32.exe /s C:\vcam\Win32\Release\VCam.dll :: 卸载:DLL 被占用时会失败,先关掉正在用它的宿主进程 C:\Windows\SysWOW64\regsvr32.exe /u /s C:\vcam\Win32\Release\VCam.dll第一次跑千万别加/s,静默模式失败时只弹一个「模块已加载但找不到入口点」的模糊提示,实际原因可能是缺依赖 DLL、可能是位数不对、也可能是 DLL 路径里有空格没引号。老老实实看弹窗文字,能省半小时。另外 DLL 一旦被某个进程加载就拿不到写锁,重新编译会报「无法打开输出文件」,这时候用tasklist /m VCam.dll找出宿主进程关掉,比重启机器快。
4.3 用 GraphStudioNext 和一段枚举代码验证
验证分两层。第一层看图:GraphStudioNext 里Graph → Insert Filter,左边列表找Video Capture Sources分类,能看到VCAM Virtual Camera说明分类注册对了;把它插进图里直接 Render,能出画面说明格式协商和 FillBuffer 都对。这一步能排掉八成问题。
第二层用代码枚举,确认宿主进程真的能看到:
// 枚举 VideoInputDevice 分类,输出所有设备友好名 HRESULT EnumCameras() { ICreateDevEnum *pDevEnum = NULL; IEnumMoniker *pEnum = NULL; HRESULT hr = CoCreateInstance(CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC_SERVER, IID_ICreateDevEnum, (void**)&pDevEnum); if (FAILED(hr)) return hr; hr = pDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &pEnum, 0); // 分类为空时返回 S_FALSE,不是 S_OK,这个分支必须单独判 if (hr != S_OK) { pDevEnum->Release(); return hr; } IMoniker *pMoniker = NULL; while (pEnum->Next(1, &pMoniker, NULL) == S_OK) { IPropertyBag *pBag = NULL; if (SUCCEEDED(pMoniker->BindToStorage(NULL, NULL, IID_IPropertyBag, (void**)&pBag))) { VARIANT v; VariantInit(&v); if (SUCCEEDED(pBag->Read(L"FriendlyName", &v, NULL))) { wprintf(L"%s\n", v.bstrVal); VariantClear(&v); } pBag->Release(); } pMoniker->Release(); } pEnum->Release(); pDevEnum->Release(); return S_OK; }这段代码的位数必须和目标应用一致。用 x64 编译出来看不到设备,先别怀疑注册表,换 x86 再跑一遍,多半就出来了——这是 32/64 双份注册没做全的典型表现。
5. 排查:注册成功但应用里看不到摄像头的 5 种情况
5.1 现象:GraphStudioNext 能看到,目标软件列表里没有
原因基本就一个:那个软件不枚举 DirectShow 分类,走的是 Media Foundation。Win10 之后系统相机、新版浏览器、不少会议客户端都换到了 MF 采集路径,它们连CLSID_VideoInputDeviceCategory都不查。
解决:先确认目标软件设置里有没有「DirectShow」或「兼容模式」开关,有就切过去;没有的话这条路走不通,别在注册表上反复折腾。真需要覆盖这类程序,只能再加一个 MF 虚拟摄像头实现(MFCreateVirtualCamera那套),两个组件并存,各自注册各自的分类。判断成本很低:拿 GraphStudioNext 建一个 Capture → Null Renderer 的图跑十秒,能出帧就说明组件本身没问题,剩下全是兼容面的事。
5.2 现象:64 位程序能看到,32 位程序看不到(或反过来)
原因:DirectShow 的设备枚举受 WOW64 注册表重定向影响,32 位进程只看得到 32 位注册的过滤器,64 位进程只看 64 位注册的。只编了一份、只注册了一份,另一半进程就是空的。错误码常见0x80040154(类未注册)。
解决:同一份源码出 Win32 和 x64 两个配置,分别用对应的 regsvr32 注册,装的时候两个都装。发布包里最好带一个判断脚本,根据目标应用的位数决定装哪个,省得用户装错。这条踩过一次就会记住:以后所有 DirectShow 组件的构建脚本里,双平台注册是默认动作,不是可选项。
5.3 现象:设备能选到,一点就黑屏或者转圈超时
原因通常出在 FillBuffer 的返回值或者缓冲大小上。返回S_FALSE会让基类判定流结束;SetActualDataLength没调会让下游读到脏数据;DecideBufferSize里没校验actual.cbBuffer,分配器给的实际值小于需求,写入时越界或者写不全。
解决:在 FillBuffer 里对每个返回值都加日志,尤其确认最终返回的是S_OK。检查DecideBufferSize是否比较了 actual 和请求值。还有一种更隐蔽的:时间戳没有单调递增(比如m_rtLast在多线程下被并发改了),下游按时间戳排序时会认为帧乱序,直接丢弃,表现就是「连上了但一帧都没有」。加锁或者用原子操作保护m_rtLast能解决。
5.4 现象:画面出来了,但是上下颠倒或者有斜纹
原因:biHeight为正数表示 DIB 自下而上存储,写帧时没做行翻转就是上下颠倒;stride 没按 4 字节对齐,行尾少写几个字节,就会读到上一
本文还有配套的精品资源,点击获取